diff --git a/app/basal-metabolism-detail.tsx b/app/basal-metabolism-detail.tsx new file mode 100644 index 0000000..124e6e5 --- /dev/null +++ b/app/basal-metabolism-detail.tsx @@ -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('week'); + + // 说明弹窗状态 + const [infoModalVisible, setInfoModalVisible] = useState(false); + + // 数据状态 + const [chartData, setChartData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // 缓存和防抖相关,参照BasalMetabolismCard + const [cacheRef] = useState(() => new Map()); + const [loadingRef] = useState(() => new Map>()); + 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 => { + 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 => { + 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 ( + + {/* 背景渐变 */} + + + {/* 头部导航 */} + setInfoModalVisible(true)} + style={styles.infoButton} + > + + + } + /> + + + {/* 日期选择器 */} + + + + + {/* 当前日期基础代谢显示 */} + + + {dayjs(currentSelectedDate).format('M月D日')} 基础代谢 + + + + {(() => { + const selectedDateData = chartData.find(item => + dayjs(item.date).isSame(currentSelectedDate, 'day') + ); + if (selectedDateData?.value) { + return Math.round(selectedDateData.value).toString(); + } + return '--'; + })()} + + 千卡 + + {bmrRange && ( + + 正常范围: {bmrRange.min}-{bmrRange.max} 千卡 + + )} + + + {/* 基础代谢统计 */} + + 基础代谢统计 + + {/* Tab 切换 */} + + handleTabPress('week')} + activeOpacity={0.7} + > + + 按周 + + + handleTabPress('month')} + activeOpacity={0.7} + > + + 按月 + + + + + {/* 柱状图 */} + {isLoading ? ( + + + 加载中... + + ) : error ? ( + + 加载失败: {error} + { + // 重新加载数据 + 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} + > + 重试 + + + ) : processedChartData.datasets.length > 0 && processedChartData.datasets[0].data.length > 0 ? ( + `${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} + /> + ) : ( + + 暂无数据 + + )} + + + + {/* 基础代谢说明弹窗 */} + setInfoModalVisible(false)} + > + + + {/* 关闭按钮 */} + setInfoModalVisible(false)} + > + × + + + {/* 标题 */} + 基础代谢 + + {/* 基础代谢定义 */} + + 基础代谢,也称基础代谢率(BMR),是指人体在完全静息状态下维持基本生命功能(心跳、呼吸、体温调节等)所需的最低能量消耗,通常以卡路里为单位。 + + + {/* 为什么重要 */} + 为什么重要? + + 基础代谢占总能量消耗的60-75%,是能量平衡的基础。了解您的基础代谢有助于制定科学的营养计划、优化体重管理策略,以及评估代谢健康状态。 + + + {/* 正常范围 */} + 正常范围 + + - 男性:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄 + 5 + + + - 女性:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄 - 161 + + + {bmrRange ? ( + <> + 您的正常区间:{bmrRange.min}-{bmrRange.max}千卡/天 + + (在公式基础计算值上下浮动15%都属于正常范围) + + + 基于您的信息:{userProfile.gender === 'male' ? '男性' : '女性'},{userAge}岁,{userProfile.height}cm,{userProfile.weight}kg + + + ) : ( + 请完善基本信息以计算您的代谢率 + )} + + {/* 提高代谢率的策略 */} + 提高代谢率的策略 + 科学研究支持以下方法: + + + 1.增加肌肉量 (每周2-3次力量训练) + 2.高强度间歇训练 (HIIT) + 3.充分蛋白质摄入 (体重每公斤1.6-2.2g) + 4.保证充足睡眠 (7-9小时/晚) + 5.避免过度热量限制 (不低于BMR的80%) + + + + + + ); +} + +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, + }, +}); \ No newline at end of file diff --git a/app/water-settings.tsx b/app/water-settings.tsx index 138d597..fe4e38b 100644 --- a/app/water-settings.tsx +++ b/app/water-settings.tsx @@ -1,5 +1,4 @@ import { Colors } from '@/constants/Colors'; -import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; import { useWaterDataByDate } from '@/hooks/useWaterData'; import { getQuickWaterAmount, setQuickWaterAmount } from '@/utils/userPreferences'; @@ -34,7 +33,6 @@ const WaterSettings: React.FC = () => { const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>(); const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; - const { ensureLoggedIn } = useAuthGuard(); const [dailyGoal, setDailyGoal] = useState('2000'); const [quickAddAmount, setQuickAddAmount] = useState('250'); @@ -50,18 +48,6 @@ const WaterSettings: React.FC = () => { // 使用新的 hook 来处理指定日期的饮水数据 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 quickAddPresets = [100, 150, 200, 250, 300, 350, 400, 500]; diff --git a/components/BasalMetabolismCard.tsx b/components/BasalMetabolismCard.tsx index c15b9ba..4987b60 100644 --- a/components/BasalMetabolismCard.tsx +++ b/components/BasalMetabolismCard.tsx @@ -6,7 +6,7 @@ import dayjs from 'dayjs'; import { Image } from 'expo-image'; import { router } from 'expo-router'; 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 { selectedDate?: Date; @@ -16,7 +16,6 @@ interface BasalMetabolismCardProps { export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCardProps) { const [basalMetabolism, setBasalMetabolism] = useState(null); const [loading, setLoading] = useState(false); - const [modalVisible, setModalVisible] = useState(false); // 获取用户基本信息 const userProfile = useAppSelector(selectUserProfile); @@ -154,7 +153,7 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard <> setModalVisible(true)} + onPress={() => router.push(ROUTES.BASAL_METABOLISM_DETAIL)} activeOpacity={0.8} > {/* 头部区域 */} @@ -179,86 +178,6 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard 千卡/日 - - {/* 基础代谢详情弹窗 */} - setModalVisible(false)} - > - - - {/* 关闭按钮 */} - setModalVisible(false)} - > - × - - - {/* 标题 */} - 基础代谢 - - {/* 基础代谢定义 */} - - 基础代谢,也称基础代谢率(BMR),是指人体在完全静息状态下维持基本生命功能(心跳、呼吸、体温调节等)所需的最低能量消耗,通常以卡路里为单位。 - - - {/* 为什么重要 */} - 为什么重要? - - 基础代谢占总能量消耗的60-75%,是能量平衡的基础。了解您的基础代谢有助于制定科学的营养计划、优化体重管理策略,以及评估代谢健康状态。 - - - {/* 正常范围 */} - 正常范围 - - - 男性:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄 + 5 - - - - 女性:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄 - 161 - - - {bmrRange ? ( - <> - 您的正常区间:{bmrRange.min}-{bmrRange.max}千卡/天 - - (在公式基础计算值上下浮动15%都属于正常范围) - - - 基于您的信息:{userProfile.gender === 'male' ? '男性' : '女性'},{userAge}岁,{userProfile.height}cm,{userProfile.weight}kg - - - ) : ( - <> - 请完善基本信息以计算您的代谢率 - { - setModalVisible(false); - router.push(ROUTES.PROFILE_EDIT); - }} - > - 前往完善资料 - - - )} - - {/* 提高代谢率的策略 */} - 提高代谢率的策略 - 科学研究支持以下方法: - - - 1.增加肌肉量 (每周2-3次力量训练) - 2.高强度间歇训练 (HIIT) - 3.充分蛋白质摄入 (体重每公斤1.6-2.2g) - 4.保证充足睡眠 (7-9小时/晚) - 5.避免过度热量限制 (不低于BMR的80%) - - - - ); } @@ -352,128 +271,4 @@ const styles = StyleSheet.create({ color: '#64748B', 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', - }, }); diff --git a/components/StressMeter.tsx b/components/StressMeter.tsx index 7bef5bf..adbcd65 100644 --- a/components/StressMeter.tsx +++ b/components/StressMeter.tsx @@ -1,4 +1,4 @@ -import { fetchHRVForDate } from '@/utils/health'; +import { fetchHRVWithStatus } from '@/utils/health'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import React, { useEffect, useState } from 'react'; @@ -34,13 +34,24 @@ export function StressMeter({ curDate }: StressMeterProps) { const getHrvData = async () => { try { - const data = await fetchHRVForDate(curDate) + console.log('StressMeter: 开始获取HRV数据...', curDate); - if (data) { - setHrvValue(Math.round(data.value)) + // 使用智能HRV数据获取功能 + 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) { - + console.error('StressMeter: 获取HRV数据失败:', error); + setHrvValue(0); } } diff --git a/components/WaterIntakeCard.tsx b/components/WaterIntakeCard.tsx index 0c3492d..1b4c819 100644 --- a/components/WaterIntakeCard.tsx +++ b/components/WaterIntakeCard.tsx @@ -1,4 +1,3 @@ -import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useWaterDataByDate } from '@/hooks/useWaterData'; import { getQuickWaterAmount } from '@/utils/userPreferences'; import { useFocusEffect } from '@react-navigation/native'; @@ -28,7 +27,6 @@ const WaterIntakeCard: React.FC = ({ selectedDate }) => { const router = useRouter(); - const { ensureLoggedIn } = useAuthGuard(); const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord } = useWaterDataByDate(selectedDate); const [quickWaterAmount, setQuickWaterAmount] = useState(150); // 默认值,将从用户偏好中加载 @@ -123,12 +121,6 @@ const WaterIntakeCard: React.FC = ({ // 处理添加喝水 - 右上角按钮直接添加 const handleQuickAddWater = async () => { - // 检查用户是否已登录 - const isLoggedIn = await ensureLoggedIn(); - if (!isLoggedIn) { - return; - } - // 触发震动反馈 if (process.env.EXPO_OS === 'ios') { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); @@ -145,12 +137,6 @@ const WaterIntakeCard: React.FC = ({ // 处理卡片点击 - 跳转到饮水设置页面 const handleCardPress = async () => { - // 检查用户是否已登录 - const isLoggedIn = await ensureLoggedIn(); - if (!isLoggedIn) { - return; - } - // 触发震动反馈 if (process.env.EXPO_OS === 'ios') { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); diff --git a/constants/Routes.ts b/constants/Routes.ts index 4a534f7..7610d17 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -45,6 +45,7 @@ export const ROUTES = { // 健康相关路由 FITNESS_RINGS_DETAIL: '/fitness-rings-detail', SLEEP_DETAIL: '/sleep-detail', + BASAL_METABOLISM_DETAIL: '/basal-metabolism-detail', // 任务相关路由 TASK_DETAIL: '/task-detail', diff --git a/hooks/useWaterData.ts b/hooks/useWaterData.ts index 6f9c58b..194befc 100644 --- a/hooks/useWaterData.ts +++ b/hooks/useWaterData.ts @@ -1,61 +1,97 @@ -import { CreateWaterRecordDto, UpdateWaterRecordDto, WaterRecordSource } from '@/services/waterRecords'; -import { AppDispatch, RootState } from '@/store'; -import { - createWaterRecordAction, - deleteWaterRecordAction, - fetchTodayWaterStats, - fetchWaterRecords, - fetchWaterRecordsByDateRange, - setSelectedDate, - updateWaterGoalAction, - updateWaterRecordAction, -} from '@/store/waterSlice'; -import { deleteWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health'; +import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health'; import { Toast } from '@/utils/toast.utils'; -import { getQuickWaterAmount } from '@/utils/userPreferences'; +import { getQuickWaterAmount, getWaterGoalFromStorage, setWaterGoalToStorage } from '@/utils/userPreferences'; import { refreshWidget, syncWaterDataToWidget } from '@/utils/widgetDataSync'; import dayjs from 'dayjs'; -import { useCallback, useEffect, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +// 水分记录数据结构 +export interface WaterRecord { + id: string; + amount: number; + recordedAt: string; + createdAt: string; + note?: string; +} + +// 水分统计数据结构 +export interface WaterStats { + totalAmount: number; + completionRate: number; + recordCount: number; +} + +// 将 HealthKit 数据转换为应用数据格式 +const convertHealthKitToWaterRecord = (healthKitRecord: any): WaterRecord => { + return { + id: healthKitRecord.id || `${healthKitRecord.startDate}_${healthKitRecord.value}`, + amount: Math.round(healthKitRecord.value), // HealthKit 已经返回毫升数值 + recordedAt: healthKitRecord.startDate, + createdAt: healthKitRecord.endDate, + note: healthKitRecord.metadata?.note || undefined, + }; +}; + +// 创建日期范围选项 +function createDateRange(date: string): { startDate: string; endDate: string } { + return { + startDate: dayjs(date).startOf('day').toDate().toISOString(), + endDate: dayjs(date).endOf('day').toDate().toISOString() + }; +} export const useWaterData = () => { - const dispatch = useDispatch(); + // 本地状态管理 + const [loading, setLoading] = useState({ + records: false, + stats: false, + goal: false + }); + const [error, setError] = useState(null); + const [dailyWaterGoal, setDailyWaterGoal] = useState(2000); + const [waterRecords, setWaterRecords] = useState<{ [date: string]: WaterRecord[] }>({}); + const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD')); - // 选择器 - const todayStats = useSelector((state: RootState) => state.water.todayStats); - const dailyWaterGoal = useSelector((state: RootState) => state.water.dailyWaterGoal); - const waterRecords = useSelector((state: RootState) => - state.water.waterRecords[dayjs().format('YYYY-MM-DD')] || [] - ); - const waterRecordsMeta = useSelector((state: RootState) => - state.water.waterRecordsMeta[dayjs().format('YYYY-MM-DD')] || { - total: 0, - page: 1, - limit: 20, - hasMore: false - } - ); - const selectedDate = useSelector((state: RootState) => state.water.selectedDate); - const loading = useSelector((state: RootState) => state.water.loading); - const error = useSelector((state: RootState) => state.water.error); - - // 获取指定日期的记录(支持分页) + // 获取指定日期的记录 const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => { - await dispatch(fetchWaterRecords({ date, page, limit })); - }, [dispatch]); + setLoading(prev => ({ ...prev, records: true })); + setError(null); - // 加载更多记录 - const loadMoreWaterRecords = useCallback(async () => { - const currentMeta = waterRecordsMeta; - if (currentMeta.hasMore && !loading.records) { - const nextPage = currentMeta.page + 1; - await dispatch(fetchWaterRecords({ - date: selectedDate, - page: nextPage, - limit: currentMeta.limit + try { + const options = createDateRange(date); + const healthKitRecords = await getWaterIntakeFromHealthKit(options); + + // 转换数据格式并按时间排序 + const convertedRecords = healthKitRecords + .map(convertHealthKitToWaterRecord) + .sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime()); + + // 应用分页逻辑 + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedRecords = convertedRecords.slice(startIndex, endIndex); + + setWaterRecords(prev => ({ + ...prev, + [date]: page === 1 ? paginatedRecords : [...(prev[date] || []), ...paginatedRecords] })); + + return paginatedRecords; + } catch (error) { + console.error('获取饮水记录失败:', error); + setError('获取饮水记录失败'); + Toast.error('获取饮水记录失败'); + return []; + } finally { + setLoading(prev => ({ ...prev, records: false })); } - }, [dispatch, waterRecordsMeta, loading.records, selectedDate]); + }, []); + + // 加载更多记录(占位符,HealthKit一次性返回所有数据) + const loadMoreWaterRecords = useCallback(async () => { + // HealthKit通常一次性返回所有数据,这里保持接口一致性 + return; + }, []); // 获取日期范围的记录 const getWaterRecordsByDateRange = useCallback(async ( @@ -64,206 +100,217 @@ export const useWaterData = () => { page = 1, limit = 20 ) => { - await dispatch(fetchWaterRecordsByDateRange({ startDate, endDate, page, limit })); - }, [dispatch]); + setLoading(prev => ({ ...prev, records: true })); + setError(null); + + try { + const options = { + startDate: dayjs(startDate).startOf('day').toDate().toISOString(), + endDate: dayjs(endDate).endOf('day').toDate().toISOString() + }; + + const healthKitRecords = await getWaterIntakeFromHealthKit(options); + + // 转换数据格式并按时间排序 + const convertedRecords = healthKitRecords + .map(convertHealthKitToWaterRecord) + .sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime()); + + // 按日期分组 + const recordsByDate: { [date: string]: WaterRecord[] } = {}; + convertedRecords.forEach(record => { + const date = dayjs(record.recordedAt).format('YYYY-MM-DD'); + if (!recordsByDate[date]) { + recordsByDate[date] = []; + } + recordsByDate[date].push(record); + }); + + setWaterRecords(prev => ({ ...prev, ...recordsByDate })); + return recordsByDate; + } catch (error) { + console.error('获取日期范围饮水记录失败:', error); + setError('获取日期范围饮水记录失败'); + Toast.error('获取日期范围饮水记录失败'); + return {}; + } finally { + setLoading(prev => ({ ...prev, records: false })); + } + }, []); // 加载今日数据 const loadTodayData = useCallback(() => { - dispatch(fetchTodayWaterStats()); - dispatch(fetchWaterRecords({ date: dayjs().format('YYYY-MM-DD') })); - }, [dispatch]); + const today = dayjs().format('YYYY-MM-DD'); + getWaterRecordsByDate(today); + }, [getWaterRecordsByDate]); // 加载指定日期数据 const loadDataByDate = useCallback((date: string) => { - dispatch(setSelectedDate(date)); - dispatch(fetchWaterRecords({ date })); - }, [dispatch]); + setSelectedDate(date); + getWaterRecordsByDate(date); + }, [getWaterRecordsByDate]); // 创建喝水记录 const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => { - const dto: CreateWaterRecordDto = { - amount, - source: WaterRecordSource.Manual, - recordedAt, - }; - try { - await dispatch(createWaterRecordAction(dto)).unwrap(); + const recordTime = recordedAt || dayjs().toISOString(); - // 同步到 HealthKit - try { - const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordedAt); - if (!healthKitSuccess) { - console.warn('同步饮水记录到 HealthKit 失败,但应用内记录已保存'); - } - } catch (healthError) { - console.error('HealthKit 同步错误:', healthError); - // HealthKit 同步失败不影响主要功能 + // 保存到 HealthKit + const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime); + if (!healthKitSuccess) { + Toast.error('保存到 HealthKit 失败'); + return false; } - // 重新获取今日统计并等待完成 - const updatedStats = await dispatch(fetchTodayWaterStats()).unwrap(); + // 重新获取当前日期的数据以刷新界面 + const date = dayjs(recordTime).format('YYYY-MM-DD'); + await getWaterRecordsByDate(date); - // 同步数据到Widget - try { + // 如果是今天的数据,更新Widget + if (date === dayjs().format('YYYY-MM-DD')) { + const todayRecords = waterRecords[date] || []; + const totalAmount = todayRecords.reduce((sum, record) => sum + record.amount, 0); const quickAddAmount = await getQuickWaterAmount(); - await syncWaterDataToWidget({ - currentIntake: updatedStats.totalAmount, - dailyGoal: updatedStats.dailyGoal, - quickAddAmount, - }); - - // 刷新Widget - await refreshWidget(); - } catch (widgetError) { - console.error('Widget 同步错误:', widgetError); - // Widget 同步失败不影响主要功能 + try { + await syncWaterDataToWidget({ + currentIntake: totalAmount, + dailyGoal: dailyWaterGoal, + quickAddAmount, + }); + await refreshWidget(); + } catch (widgetError) { + console.error('Widget 同步错误:', widgetError); + } } + Toast.success('添加饮水记录成功'); return true; } catch (error: any) { console.error('添加喝水记录失败:', error); + Toast.error(error?.message || '添加喝水记录失败'); + return false; + } + }, [getWaterRecordsByDate, waterRecords, dailyWaterGoal]); - // 根据错误类型显示不同的提示信息 - let errorMessage = '添加喝水记录失败'; + // 更新喝水记录(HealthKit不支持更新,只能删除后重新添加) + const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => { + try { + // 找到要更新的记录 + let recordToUpdate: WaterRecord | null = null; + let recordDate = ''; - if (error?.message) { - if (error.message.includes('网络')) { - errorMessage = '网络连接失败,请检查网络后重试'; - } else if (error.message.includes('参数')) { - errorMessage = '参数错误,请重新输入'; - } else if (error.message.includes('权限')) { - errorMessage = '权限不足,请重新登录'; - } else if (error.message.includes('服务器')) { - errorMessage = '服务器繁忙,请稍后重试'; - } else { - errorMessage = error.message; + for (const [date, records] of Object.entries(waterRecords)) { + const record = records.find(r => r.id === id); + if (record) { + recordToUpdate = record; + recordDate = date; + break; } } - Toast.error(errorMessage); - return false; - } - }, [dispatch]); - - // 更新喝水记录 - const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => { - const dto: UpdateWaterRecordDto = { - id, - amount, - note, - recordedAt, - }; - - try { - await dispatch(updateWaterRecordAction(dto)).unwrap(); - // 重新获取今日统计并等待完成 - const updatedStats = await dispatch(fetchTodayWaterStats()).unwrap(); - - // 同步数据到Widget - try { - const quickAddAmount = await getQuickWaterAmount(); - - await syncWaterDataToWidget({ - currentIntake: updatedStats.totalAmount, - dailyGoal: updatedStats.dailyGoal, - quickAddAmount, - }); - - // 刷新Widget - await refreshWidget(); - } catch (widgetError) { - console.error('Widget 更新同步错误:', widgetError); - // Widget 同步失败不影响主要功能 + if (!recordToUpdate) { + Toast.error('找不到要更新的记录'); + return false; } - return true; + // 先删除旧记录 + await deleteWaterIntakeFromHealthKit(id, recordToUpdate.recordedAt); + + // 添加新记录 + const newAmount = amount ?? recordToUpdate.amount; + const newRecordedAt = recordedAt ?? recordToUpdate.recordedAt; + + const success = await addWaterRecord(newAmount, newRecordedAt); + + if (success) { + Toast.success('更新饮水记录成功'); + } + + return success; } catch (error: any) { console.error('更新喝水记录失败:', error); - - let errorMessage = '更新喝水记录失败'; - if (error?.message) { - errorMessage = error.message; - } - - Toast.error(errorMessage); + Toast.error(error?.message || '更新喝水记录失败'); return false; } - }, [dispatch]); + }, [waterRecords, addWaterRecord]); // 删除喝水记录 const removeWaterRecord = useCallback(async (id: string) => { try { - // 在删除前,尝试获取记录信息用于 HealthKit 同步 - const recordToDelete = waterRecords.find(record => record.id === id); + // 找到要删除的记录 + let recordToDelete: WaterRecord | null = null; + let recordDate = ''; - await dispatch(deleteWaterRecordAction(id)).unwrap(); - - // 同步删除到 HealthKit - if (recordToDelete) { - try { - const healthKitSuccess = await deleteWaterIntakeFromHealthKit( - id, - recordToDelete.recordedAt || recordToDelete.createdAt - ); - if (!healthKitSuccess) { - console.warn('从 HealthKit 删除饮水记录失败,但应用内记录已删除'); - } - } catch (healthError) { - console.error('HealthKit 删除同步错误:', healthError); - // HealthKit 同步失败不影响主要功能 + for (const [date, records] of Object.entries(waterRecords)) { + const record = records.find(r => r.id === id); + if (record) { + recordToDelete = record; + recordDate = date; + break; } } - // 重新获取今日统计并等待完成 - const updatedStats = await dispatch(fetchTodayWaterStats()).unwrap(); - - // 同步数据到Widget - try { - const quickAddAmount = await getQuickWaterAmount(); - - await syncWaterDataToWidget({ - currentIntake: updatedStats.totalAmount, - dailyGoal: updatedStats.dailyGoal, - quickAddAmount, - }); - - // 刷新Widget - await refreshWidget(); - } catch (widgetError) { - console.error('Widget 删除同步错误:', widgetError); - // Widget 同步失败不影响主要功能 + if (!recordToDelete) { + Toast.error('找不到要删除的记录'); + return false; } + // 从 HealthKit 删除 + const healthKitSuccess = await deleteWaterIntakeFromHealthKit(id, recordToDelete.recordedAt); + if (!healthKitSuccess) { + console.warn('从 HealthKit 删除记录失败,但继续更新本地数据'); + } + + // 更新本地状态 + setWaterRecords(prev => ({ + ...prev, + [recordDate]: prev[recordDate]?.filter(record => record.id !== id) || [] + })); + + // 如果是今天的数据,更新Widget + if (recordDate === dayjs().format('YYYY-MM-DD')) { + const updatedTodayRecords = waterRecords[recordDate]?.filter(record => record.id !== id) || []; + const totalAmount = updatedTodayRecords.reduce((sum, record) => sum + record.amount, 0); + const quickAddAmount = await getQuickWaterAmount(); + + try { + await syncWaterDataToWidget({ + currentIntake: totalAmount, + dailyGoal: dailyWaterGoal, + quickAddAmount, + }); + await refreshWidget(); + } catch (widgetError) { + console.error('Widget 删除同步错误:', widgetError); + } + } + + Toast.success('删除饮水记录成功'); return true; } catch (error: any) { console.error('删除喝水记录失败:', error); - - let errorMessage = '删除喝水记录失败'; - if (error?.message) { - errorMessage = error.message; - } - - Toast.error(errorMessage); + Toast.error(error?.message || '删除喝水记录失败'); return false; } - }, [dispatch, waterRecords]); + }, [waterRecords, dailyWaterGoal]); // 更新喝水目标 const updateWaterGoal = useCallback(async (goal: number) => { try { - await dispatch(updateWaterGoalAction(goal)).unwrap(); + await setWaterGoalToStorage(goal); + setDailyWaterGoal(goal); - // 重新获取今日统计以确保数据一致性 - const updatedStats = await dispatch(fetchTodayWaterStats()).unwrap(); + // 更新Widget + const today = dayjs().format('YYYY-MM-DD'); + const todayRecords = waterRecords[today] || []; + const totalAmount = todayRecords.reduce((sum, record) => sum + record.amount, 0); - // 同步目标到Widget try { const quickAddAmount = await getQuickWaterAmount(); await syncWaterDataToWidget({ dailyGoal: goal, - currentIntake: updatedStats.totalAmount, + currentIntake: totalAmount, quickAddAmount, }); await refreshWidget(); @@ -271,36 +318,29 @@ export const useWaterData = () => { console.error('Widget 目标同步错误:', widgetError); } + Toast.success('更新饮水目标成功'); return true; } catch (error: any) { console.error('更新喝水目标失败:', error); - - let errorMessage = '更新喝水目标失败'; - if (error?.message) { - errorMessage = error.message; - } - - Toast.error(errorMessage); + Toast.error(error?.message || '更新喝水目标失败'); return false; } - }, [dispatch, todayStats]); + }, [waterRecords]); // 计算总喝水量 - const getTotalAmount = useCallback((records: any[]) => { + const getTotalAmount = useCallback((records: WaterRecord[]) => { return records.reduce((total, record) => total + record.amount, 0); }, []); // 按小时分组数据 - const getHourlyData = useCallback((records: any[]) => { + const getHourlyData = useCallback((records: WaterRecord[]) => { const hourlyData: { hour: number; amount: number }[] = Array.from({ length: 24 }, (_, i) => ({ hour: i, amount: 0, })); records.forEach(record => { - // 优先使用 recordedAt,如果没有则使用 createdAt - const dateTime = record.recordedAt || record.createdAt; - const hour = dayjs(dateTime).hour(); + const hour = dayjs(record.recordedAt).hour(); if (hour >= 0 && hour < 24) { hourlyData[hour].amount += record.amount; } @@ -315,11 +355,37 @@ export const useWaterData = () => { return Math.min((totalAmount / goal) * 100, 100); }, []); - // 初始化加载 + // 加载初始数据 useEffect(() => { - loadTodayData(); + const loadInitialData = async () => { + try { + // 加载饮水目标 + const goal = await getWaterGoalFromStorage(); + setDailyWaterGoal(goal); + + // 加载今日数据 + loadTodayData(); + } catch (error) { + console.error('加载初始数据失败:', error); + } + }; + + loadInitialData(); }, [loadTodayData]); + // 计算今日统计数据 + const todayStats = useMemo(() => { + const today = dayjs().format('YYYY-MM-DD'); + const todayRecords = waterRecords[today] || []; + const totalAmount = getTotalAmount(todayRecords); + + return { + totalAmount, + completionRate: calculateCompletionRate(totalAmount, dailyWaterGoal), + recordCount: todayRecords.length + }; + }, [waterRecords, dailyWaterGoal, getTotalAmount, calculateCompletionRate]); + // 同步初始数据到Widget useEffect(() => { const syncInitialDataToWidget = async () => { @@ -344,8 +410,13 @@ export const useWaterData = () => { // 数据 todayStats, dailyWaterGoal, - waterRecords, - waterRecordsMeta, + waterRecords: waterRecords[selectedDate] || [], + waterRecordsMeta: { + total: waterRecords[selectedDate]?.length || 0, + page: 1, + limit: 20, + hasMore: false + }, selectedDate, loading, error, @@ -368,83 +439,204 @@ export const useWaterData = () => { // 简化的Hook,只返回今日数据 export const useTodayWaterData = () => { - const { - todayStats, - dailyWaterGoal, - waterRecords, - waterRecordsMeta, - selectedDate, - loading, - error, - getWaterRecordsByDate, - loadMoreWaterRecords, - getWaterRecordsByDateRange, - addWaterRecord, - updateWaterRecord, - removeWaterRecord, - updateWaterGoal, - } = useWaterData(); + const waterData = useWaterData(); - // 获取今日记录(默认第一页) - const todayWaterRecords = useSelector((state: RootState) => - state.water.waterRecords[dayjs().format('YYYY-MM-DD')] || [] - ); + const todayRecords = useMemo(() => { + const today = dayjs().format('YYYY-MM-DD'); + return waterData.waterRecords || []; + }, [waterData.waterRecords]); - const todayMeta = useSelector((state: RootState) => - state.water.waterRecordsMeta[dayjs().format('YYYY-MM-DD')] || { - total: 0, - page: 1, - limit: 20, - hasMore: false - } - ); + const todayMeta = useMemo(() => ({ + total: todayRecords.length, + page: 1, + limit: 20, + hasMore: false + }), [todayRecords.length]); - // 获取今日记录(向后兼容) const fetchTodayWaterRecords = useCallback(async (page = 1, limit = 20) => { const today = dayjs().format('YYYY-MM-DD'); - await getWaterRecordsByDate(today, page, limit); - }, [getWaterRecordsByDate]); + await waterData.getWaterRecordsByDate(today, page, limit); + }, [waterData.getWaterRecordsByDate]); return { - todayStats, - dailyWaterGoal, - waterRecords: todayWaterRecords, + todayStats: waterData.todayStats, + dailyWaterGoal: waterData.dailyWaterGoal, + waterRecords: todayRecords, waterRecordsMeta: todayMeta, - selectedDate, - loading, - error, + selectedDate: waterData.selectedDate, + loading: waterData.loading, + error: waterData.error, fetchTodayWaterRecords, - loadMoreWaterRecords, - getWaterRecordsByDateRange, - addWaterRecord, - updateWaterRecord, - removeWaterRecord, - updateWaterGoal, + loadMoreWaterRecords: waterData.loadMoreWaterRecords, + getWaterRecordsByDateRange: waterData.getWaterRecordsByDateRange, + addWaterRecord: waterData.addWaterRecord, + updateWaterRecord: waterData.updateWaterRecord, + removeWaterRecord: waterData.removeWaterRecord, + updateWaterGoal: waterData.updateWaterGoal, }; }; -// 新增:按日期获取饮水数据的 hook +// 按日期获取饮水数据的 hook export const useWaterDataByDate = (targetDate?: string) => { - const dispatch = useDispatch(); - - // 如果没有传入日期,默认使用今天 const dateToUse = targetDate || dayjs().format('YYYY-MM-DD'); - // 选择器 - 获取指定日期的数据 - const dailyWaterGoal = useSelector((state: RootState) => state.water.dailyWaterGoal) || 0; - const waterRecords = useSelector((state: RootState) => - state.water.waterRecords[dateToUse] || [] - ); - const waterRecordsMeta = useSelector((state: RootState) => - state.water.waterRecordsMeta[dateToUse] || { - total: 0, - page: 1, - limit: 20, - hasMore: false + // 本地状态管理 + const [loading, setLoading] = useState({ + records: false, + stats: false, + goal: false + }); + const [error, setError] = useState(null); + const [dailyWaterGoal, setDailyWaterGoal] = useState(2000); + const [waterRecords, setWaterRecords] = useState([]); + + // 获取指定日期的记录 + const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => { + setLoading(prev => ({ ...prev, records: true })); + setError(null); + + try { + const options = createDateRange(date); + const healthKitRecords = await getWaterIntakeFromHealthKit(options); + + // 转换数据格式并按时间排序 + const convertedRecords = healthKitRecords + .map(convertHealthKitToWaterRecord) + .sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime()); + + setWaterRecords(convertedRecords); + return convertedRecords; + } catch (error) { + console.error('获取饮水记录失败:', error); + setError('获取饮水记录失败'); + Toast.error('获取饮水记录失败'); + return []; + } finally { + setLoading(prev => ({ ...prev, records: false })); } - ); - const loading = useSelector((state: RootState) => state.water.loading); - const error = useSelector((state: RootState) => state.water.error); + }, []); + + // 创建喝水记录 + const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => { + try { + const recordTime = recordedAt || dayjs().toISOString(); + + // 保存到 HealthKit + const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime); + if (!healthKitSuccess) { + Toast.error('保存到 HealthKit 失败'); + return false; + } + + // 重新获取当前日期的数据以刷新界面 + await getWaterRecordsByDate(dateToUse); + + // 如果是今天的数据,更新Widget + if (dateToUse === dayjs().format('YYYY-MM-DD')) { + const totalAmount = waterRecords.reduce((sum, record) => sum + record.amount, 0) + amount; + const quickAddAmount = await getQuickWaterAmount(); + + try { + await syncWaterDataToWidget({ + currentIntake: totalAmount, + dailyGoal: dailyWaterGoal, + quickAddAmount, + }); + await refreshWidget(); + } catch (widgetError) { + console.error('Widget 同步错误:', widgetError); + } + } + + Toast.success('添加饮水记录成功'); + return true; + } catch (error: any) { + console.error('添加喝水记录失败:', error); + Toast.error(error?.message || '添加喝水记录失败'); + return false; + } + }, [getWaterRecordsByDate, dateToUse, waterRecords, dailyWaterGoal]); + + // 更新喝水记录 + const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => { + try { + const recordToUpdate = waterRecords.find(r => r.id === id); + if (!recordToUpdate) { + Toast.error('找不到要更新的记录'); + return false; + } + + // 先删除旧记录 + await deleteWaterIntakeFromHealthKit(id, recordToUpdate.recordedAt); + + // 添加新记录 + const newAmount = amount ?? recordToUpdate.amount; + const newRecordedAt = recordedAt ?? recordToUpdate.recordedAt; + + const success = await addWaterRecord(newAmount, newRecordedAt); + return success; + } catch (error: any) { + console.error('更新喝水记录失败:', error); + Toast.error(error?.message || '更新喝水记录失败'); + return false; + } + }, [waterRecords, addWaterRecord]); + + // 删除喝水记录 + const removeWaterRecord = useCallback(async (id: string) => { + try { + const recordToDelete = waterRecords.find(r => r.id === id); + if (!recordToDelete) { + Toast.error('找不到要删除的记录'); + return false; + } + + // 从 HealthKit 删除 + await deleteWaterIntakeFromHealthKit(id, recordToDelete.recordedAt); + + // 重新获取数据 + await getWaterRecordsByDate(dateToUse); + + // 如果是今天的数据,更新Widget + if (dateToUse === dayjs().format('YYYY-MM-DD')) { + const updatedRecords = waterRecords.filter(record => record.id !== id); + const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0); + const quickAddAmount = await getQuickWaterAmount(); + + try { + await syncWaterDataToWidget({ + currentIntake: totalAmount, + dailyGoal: dailyWaterGoal, + quickAddAmount, + }); + await refreshWidget(); + } catch (widgetError) { + console.error('Widget 删除同步错误:', widgetError); + } + } + + Toast.success('删除饮水记录成功'); + return true; + } catch (error: any) { + console.error('删除喝水记录失败:', error); + Toast.error(error?.message || '删除喝水记录失败'); + return false; + } + }, [waterRecords, getWaterRecordsByDate, dateToUse, dailyWaterGoal]); + + // 更新喝水目标 + const updateWaterGoal = useCallback(async (goal: number) => { + try { + await setWaterGoalToStorage(goal); + setDailyWaterGoal(goal); + Toast.success('更新饮水目标成功'); + return true; + } catch (error: any) { + console.error('更新喝水目标失败:', error); + Toast.error(error?.message || '更新喝水目标失败'); + return false; + } + }, []); // 计算指定日期的统计数据 const waterStats = useMemo(() => { @@ -466,177 +658,34 @@ export const useWaterDataByDate = (targetDate?: string) => { }; }, [waterRecords, dailyWaterGoal]); - // 获取指定日期的记录 - const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => { - await dispatch(fetchWaterRecords({ date, page, limit })); - }, [dispatch]); - - // 创建喝水记录 - const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => { - const dto: CreateWaterRecordDto = { - amount, - source: WaterRecordSource.Manual, - recordedAt: recordedAt || dayjs().toISOString(), - }; - - try { - await dispatch(createWaterRecordAction(dto)).unwrap(); - - // 同步到 HealthKit - try { - const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, dto.recordedAt); - if (!healthKitSuccess) { - console.warn('同步饮水记录到 HealthKit 失败,但应用内记录已保存'); - } - } catch (healthError) { - console.error('HealthKit 同步错误:', healthError); - // HealthKit 同步失败不影响主要功能 - } - - // 重新获取当前日期的数据 - await getWaterRecordsByDate(dateToUse); - - // 如果是今天的数据,也更新今日统计 - if (dateToUse === dayjs().format('YYYY-MM-DD')) { - dispatch(fetchTodayWaterStats()); - } - - return true; - } catch (error: any) { - console.error('添加喝水记录失败:', error); - - // 根据错误类型显示不同的提示信息 - let errorMessage = '添加喝水记录失败'; - - if (error?.message) { - if (error.message.includes('网络')) { - errorMessage = '网络连接失败,请检查网络后重试'; - } else if (error.message.includes('参数')) { - errorMessage = '参数错误,请重新输入'; - } else if (error.message.includes('权限')) { - errorMessage = '权限不足,请重新登录'; - } else if (error.message.includes('服务器')) { - errorMessage = '服务器繁忙,请稍后重试'; - } else { - errorMessage = error.message; - } - } - - Toast.error(errorMessage); - return false; - } - }, [dispatch, dateToUse, getWaterRecordsByDate]); - - // 更新喝水记录 - const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => { - const dto: UpdateWaterRecordDto = { - id, - amount, - note, - recordedAt, - }; - - try { - await dispatch(updateWaterRecordAction(dto)).unwrap(); - - // 重新获取当前日期的数据 - await getWaterRecordsByDate(dateToUse); - - // 如果是今天的数据,也更新今日统计 - if (dateToUse === dayjs().format('YYYY-MM-DD')) { - dispatch(fetchTodayWaterStats()); - } - - return true; - } catch (error: any) { - console.error('更新喝水记录失败:', error); - - let errorMessage = '更新喝水记录失败'; - if (error?.message) { - errorMessage = error.message; - } - - Toast.error(errorMessage); - return false; - } - }, [dispatch, dateToUse, getWaterRecordsByDate]); - - // 删除喝水记录 - const removeWaterRecord = useCallback(async (id: string) => { - try { - // 在删除前,尝试获取记录信息用于 HealthKit 同步 - const recordToDelete = waterRecords.find(record => record.id === id); - - await dispatch(deleteWaterRecordAction(id)).unwrap(); - - // 同步删除到 HealthKit - if (recordToDelete) { - try { - const healthKitSuccess = await deleteWaterIntakeFromHealthKit( - id, - recordToDelete.recordedAt || recordToDelete.createdAt - ); - if (!healthKitSuccess) { - console.warn('从 HealthKit 删除饮水记录失败,但应用内记录已删除'); - } - } catch (healthError) { - console.error('HealthKit 删除同步错误:', healthError); - // HealthKit 同步失败不影响主要功能 - } - } - - // 重新获取当前日期的数据 - await getWaterRecordsByDate(dateToUse); - - // 如果是今天的数据,也更新今日统计 - if (dateToUse === dayjs().format('YYYY-MM-DD')) { - dispatch(fetchTodayWaterStats()); - } - - return true; - } catch (error: any) { - console.error('删除喝水记录失败:', error); - - let errorMessage = '删除喝水记录失败'; - if (error?.message) { - errorMessage = error.message; - } - - Toast.error(errorMessage); - return false; - } - }, [dispatch, dateToUse, getWaterRecordsByDate, waterRecords]); - - // 更新喝水目标 - const updateWaterGoal = useCallback(async (goal: number) => { - try { - await dispatch(updateWaterGoalAction(goal)).unwrap(); - return true; - } catch (error: any) { - console.error('更新喝水目标失败:', error); - - let errorMessage = '更新喝水目标失败'; - if (error?.message) { - errorMessage = error.message; - } - - Toast.error(errorMessage); - return false; - } - }, [dispatch]); - // 初始化加载指定日期的数据 useEffect(() => { - if (dateToUse) { - getWaterRecordsByDate(dateToUse); - } + const loadInitialData = async () => { + try { + // 加载饮水目标 + const goal = await getWaterGoalFromStorage(); + setDailyWaterGoal(goal); + + // 加载指定日期数据 + await getWaterRecordsByDate(dateToUse); + } catch (error) { + console.error('加载初始数据失败:', error); + } + }; + + loadInitialData(); }, [dateToUse, getWaterRecordsByDate]); return { waterStats, dailyWaterGoal, waterRecords, - waterRecordsMeta, + waterRecordsMeta: { + total: waterRecords.length, + page: 1, + limit: 20, + hasMore: false + }, loading, error, addWaterRecord, diff --git a/ios/Podfile.lock b/ios/Podfile.lock index edcbc91..139078a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -53,7 +53,7 @@ PODS: - ExpoModulesCore - ExpoHaptics (15.0.7): - ExpoModulesCore - - ExpoHead (6.0.6): + - ExpoHead (6.0.8): - ExpoModulesCore - RNScreens - ExpoImage (3.0.8): @@ -2657,7 +2657,7 @@ SPEC CHECKSUMS: ExpoFont: 86ceec09ffed1c99cfee36ceb79ba149074901b5 ExpoGlassEffect: 744bf0c58c26a1b0212dff92856be07b98d01d8c ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84 - ExpoHead: 78f14a8573ae5b882123b272c0af20a80bfa58f6 + ExpoHead: 5570e5edbe54fd8f88e51e8b94bf2931caaa7363 ExpoImage: e88f500585913969b930e13a4be47277eb7c6de8 ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe diff --git a/package-lock.json b/package-lock.json index a930259..1562af5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,9 @@ "@react-native-masked-view/masked-view": "^0.3.2", "@react-native-picker/picker": "2.11.2", "@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/native": "^7.1.8", + "@react-navigation/native": "^7.1.17", "@reduxjs/toolkit": "^2.9.0", "@sentry/react-native": "~7.1.0", "@types/lodash": "^4.17.20", @@ -38,13 +38,13 @@ "expo-linking": "~8.0.8", "expo-notifications": "~0.32.11", "expo-quick-actions": "^6.0.0", - "expo-router": "~6.0.6", + "expo-router": "~6.0.8", "expo-splash-screen": "~31.0.10", "expo-sqlite": "^16.0.8", "expo-status-bar": "~3.0.8", "expo-symbols": "~1.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", "lodash": "^4.17.21", "lottie-react-native": "^7.3.4", @@ -68,7 +68,6 @@ "react-native-web": "^0.21.1", "react-native-webview": "13.16.0", "react-native-wheel-picker-expo": "^0.5.4", - "react-native-worklets": "0.5.1", "react-redux": "^9.2.0" }, "devDependencies": { @@ -1409,6 +1408,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -7963,14 +7963,14 @@ } }, "node_modules/expo-router": { - "version": "6.0.6", - "resolved": "https://mirrors.tencent.com/npm/expo-router/-/expo-router-6.0.6.tgz", - "integrity": "sha512-uSuKQanivBI9RtwmAznLI7It5aPwQLVL2tVBPAOJ70tv6BzP62SpVCf0I8o0j9PmEzORPRLrU2LbQOL962yBHg==", + "version": "6.0.8", + "resolved": "https://mirrors.tencent.com/npm/expo-router/-/expo-router-6.0.8.tgz", + "integrity": "sha512-cx6vFvBrfPNHpNbN2ij2mF5JKE4JXyq+dJVmWNqt7JplA0aohOOKXS/KQ9vQy88HpnrcJMuYqUNHp44aWyce7g==", "license": "MIT", "dependencies": { "@expo/metro-runtime": "^6.1.2", "@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-tabs": "^1.1.12", "@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", "integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^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", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index f2f9075..750fc8d 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,9 @@ "@react-native-masked-view/masked-view": "^0.3.2", "@react-native-picker/picker": "2.11.2", "@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/native": "^7.1.8", + "@react-navigation/native": "^7.1.17", "@reduxjs/toolkit": "^2.9.0", "@sentry/react-native": "~7.1.0", "@types/lodash": "^4.17.20", @@ -42,13 +42,13 @@ "expo-linking": "~8.0.8", "expo-notifications": "~0.32.11", "expo-quick-actions": "^6.0.0", - "expo-router": "~6.0.6", + "expo-router": "~6.0.8", "expo-splash-screen": "~31.0.10", "expo-sqlite": "^16.0.8", "expo-status-bar": "~3.0.8", "expo-symbols": "~1.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", "lodash": "^4.17.21", "lottie-react-native": "^7.3.4", @@ -72,7 +72,6 @@ "react-native-web": "^0.21.1", "react-native-webview": "13.16.0", "react-native-wheel-picker-expo": "^0.5.4", - "react-native-worklets": "0.5.1", "react-redux": "^9.2.0" }, "devDependencies": { @@ -83,4 +82,4 @@ "typescript": "~5.9.2" }, "private": true -} +} \ No newline at end of file diff --git a/utils/health.ts b/utils/health.ts index 7c413d9..63d4162 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -518,32 +518,70 @@ async function fetchHeartRateVariability(options: HealthDataOptions): Promise
0) { let selectedSample: any = null; + let bestQuality = -1; - console.log('result~~~', result); + console.log(`获取到 ${result.data.length} 个HRV样本`); - // 优先使用优化后的最佳质量值对应的样本 + // 首先尝试使用最佳质量值 if (result.bestQualityValue && typeof result.bestQualityValue === 'number') { const qualityValue = validateHRVValue(result.bestQualityValue); 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数据(最佳质量)', { - value: qualityValue, - totalSamples: result.data.length, - recordedAt: selectedSample.endDate - }); + if (sampleValue !== null && sampleQuality > bestQuality) { + bestQuality = sampleQuality; + selectedSample = sample; + } + } + + if (selectedSample) { + logSuccess('HRV数据(最佳质量)', { + value: qualityValue, + qualityScore: bestQuality, + totalSamples: result.data.length, + recordedAt: selectedSample.endDate + }); + } } } - // 如果没有找到最佳质量样本,使用第一个有效样本 + // 如果没有找到最佳质量样本,或者最佳质量值无效,重新评估所有样本 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); if (sampleValue !== null) { selectedSample = sample; - console.log('使用有效HRV样本:', { + bestQuality = sample.qualityScore || 0; + console.log('选择最佳HRV样本:', { value: sampleValue, + qualityScore: bestQuality, + isManual: sample.isManualMeasurement, source: sample.source?.name, recordedAt: sample.endDate }); @@ -733,8 +771,35 @@ export async function fetchTodayHealthData(): Promise { export async function fetchHRVForDate(date: Date): Promise { console.log('开始获取指定日期HRV数据...', date); + + // 首先尝试获取指定日期的HRV数据 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 { @@ -1189,3 +1254,117 @@ export async function fetchHRVWithAnalysis(date: Date): Promise<{ hrvData: HRVDa } } + +// 智能HRV数据获取 - 优先获取实时数据,如果没有则获取历史数据 +export async function fetchSmartHRVData(date: Date): Promise { + 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数据失败' + }; + } +} diff --git a/utils/userPreferences.ts b/utils/userPreferences.ts index b24fa35..504930f 100644 --- a/utils/userPreferences.ts +++ b/utils/userPreferences.ts @@ -3,6 +3,7 @@ import AsyncStorage from '@/utils/kvStore'; // 用户偏好设置的存储键 const PREFERENCES_KEYS = { QUICK_WATER_AMOUNT: 'user_preference_quick_water_amount', + WATER_GOAL: 'user_preference_water_goal', NOTIFICATION_ENABLED: 'user_preference_notification_enabled', FITNESS_EXERCISE_MINUTES_INFO_DISMISSED: 'user_preference_fitness_exercise_minutes_info_dismissed', FITNESS_ACTIVE_HOURS_INFO_DISMISSED: 'user_preference_fitness_active_hours_info_dismissed', @@ -11,6 +12,7 @@ const PREFERENCES_KEYS = { // 用户偏好设置接口 export interface UserPreferences { quickWaterAmount: number; + waterGoal: number; notificationEnabled: boolean; fitnessExerciseMinutesInfoDismissed: boolean; fitnessActiveHoursInfoDismissed: boolean; @@ -19,6 +21,7 @@ export interface UserPreferences { // 默认的用户偏好设置 const DEFAULT_PREFERENCES: UserPreferences = { quickWaterAmount: 150, // 默认快速添加饮水量为 150ml + waterGoal: 2000, // 默认每日饮水目标为 2000ml notificationEnabled: true, // 默认开启消息推送 fitnessExerciseMinutesInfoDismissed: false, // 默认显示锻炼分钟说明 fitnessActiveHoursInfoDismissed: false, // 默认显示活动小时说明 @@ -30,12 +33,14 @@ const DEFAULT_PREFERENCES: UserPreferences = { export const getUserPreferences = async (): Promise => { try { 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 fitnessExerciseMinutesInfoDismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_EXERCISE_MINUTES_INFO_DISMISSED); const fitnessActiveHoursInfoDismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_ACTIVE_HOURS_INFO_DISMISSED); return { quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount, + waterGoal: waterGoal ? parseInt(waterGoal, 10) : DEFAULT_PREFERENCES.waterGoal, notificationEnabled: notificationEnabled !== null ? notificationEnabled === 'true' : DEFAULT_PREFERENCES.notificationEnabled, fitnessExerciseMinutesInfoDismissed: fitnessExerciseMinutesInfoDismissed !== null ? fitnessExerciseMinutesInfoDismissed === 'true' : DEFAULT_PREFERENCES.fitnessExerciseMinutesInfoDismissed, fitnessActiveHoursInfoDismissed: fitnessActiveHoursInfoDismissed !== null ? fitnessActiveHoursInfoDismissed === 'true' : DEFAULT_PREFERENCES.fitnessActiveHoursInfoDismissed, @@ -74,6 +79,34 @@ export const getQuickWaterAmount = async (): Promise => { } }; +/** + * 设置每日饮水目标 + * @param goal 饮水目标(毫升) + */ +export const setWaterGoalToStorage = async (goal: number): Promise => { + 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 => { + 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 是否开启消息推送