From e6dfd4d59ac8afe2dcad23ab52d610a22003c877 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 23 Sep 2025 10:01:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E9=87=8D=E6=9E=84=E8=90=A5?= =?UTF-8?q?=E5=85=BB=E5=8D=A1=E7=89=87=E6=95=B0=E6=8D=AE=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF=E6=8C=81=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E4=BB=A3=E8=B0=A2=E4=B8=8E=E8=BF=90=E5=8A=A8=E6=B6=88=E8=80=97?= =?UTF-8?q?=E5=88=86=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 fetchCompleteNutritionCardData 异步 action,统一拉取营养、健康与基础代谢数据 - NutritionRadarCard 改用 Redux 数据源,移除 props 透传,自动根据日期刷新 - BasalMetabolismCard 新增详情弹窗,展示 BMR 计算公式、正常区间及提升策略 - StepsCard 与 StepsCardOptimized 引入 InteractionManager 与动画懒加载,减少 UI 阻塞 - HealthKitManager 新增饮水读写接口,支持将饮水记录同步至 HealthKit - 移除 statistics 页面冗余 mock 与 nutrition/health 重复请求,缓存时间统一为 5 分钟 --- app/(tabs)/statistics.tsx | 87 +------- app/nutrition/records.tsx | 23 +- components/BasalMetabolismCard.tsx | 309 ++++++++++++++++++++++++--- components/NutritionRadarCard.tsx | 80 ++++--- components/StepsCard.tsx | 108 ++++++---- components/StepsCardOptimized.tsx | 323 +++++++++++++++++++++++++++++ components/WaterIntakeCard.tsx | 2 +- ios/OutLive/HealthKitManager.m | 9 + ios/OutLive/HealthKitManager.swift | 152 +++++++++++++- store/nutritionSlice.ts | 155 ++++++++++++++ utils/health.ts | 70 +++++-- 11 files changed, 1115 insertions(+), 203 deletions(-) create mode 100644 components/StepsCardOptimized.tsx diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 40d0cff..93ca108 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -16,11 +16,9 @@ import { useAuthGuard } from '@/hooks/useAuthGuard'; import { BackgroundTaskManager } from '@/services/backgroundTaskManager'; import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; -import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice'; import { fetchTodayWaterStats } from '@/store/waterSlice'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health'; -import { getTestHealthData } from '@/utils/mockHealthData'; import dayjs from 'dayjs'; import { LinearGradient } from 'expo-linear-gradient'; import { debounce } from 'lodash'; @@ -37,13 +35,10 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; // 浮动动画组件 -const FloatingCard = ({ children, delay = 0, style }: { +const FloatingCard = ({ children, style }: { children: React.ReactNode; - delay?: number; style?: any; }) => { - - return ( s.user.profile?.dailyStepsGoal) ?? 2000; - const userProfile = useAppSelector((s) => s.user.profile); - - // 开发调试:设置为true来使用mock数据 - // 在真机测试时,可以暂时设置为true来验证组件显示逻辑 - const useMockData = false; // 改为true来启用mock数据调试 const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard(); @@ -83,22 +73,11 @@ export default function ExploreScreen() { return dayjs(currentSelectedDate).format('YYYY-MM-DD'); }, [currentSelectedDate]); - // 从 Redux 获取指定日期的健康数据 - const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString)); - - - // 解构健康数据(支持mock数据) - const mockData = useMockData ? getTestHealthData('mock') : null; - const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null); - // 用于触发动画重置的 token(当日期或数据变化时更新) const [animToken, setAnimToken] = useState(0); - // 从 Redux 获取营养数据 - const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString)); - // 心情相关状态 const dispatch = useAppDispatch(); @@ -110,7 +89,6 @@ export default function ExploreScreen() { // 请求状态管理,防止重复请求 const loadingRef = useRef({ health: false, - nutrition: false, mood: false }); @@ -119,14 +97,14 @@ export default function ExploreScreen() { const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`; - // 检查数据是否需要刷新(2分钟内不重复拉取,对营养数据更严格) + // 检查数据是否需要刷新(5分钟内不重复拉取) const shouldRefreshData = (dateKey: string, dataType: string) => { const cacheKey = `${dateKey}-${dataType}`; const lastUpdate = dataTimestampRef.current[cacheKey]; const now = Date.now(); - // 营养数据使用更短的缓存时间,其他数据使用5分钟 - const cacheTime = dataType === 'nutrition' ? 2 * 60 * 1000 : 5 * 60 * 1000; + // 使用5分钟缓存时间 + const cacheTime = 5 * 60 * 1000; return !lastUpdate || (now - lastUpdate) > cacheTime; }; @@ -257,45 +235,6 @@ export default function ExploreScreen() { }; // 加载营养数据 - const loadNutritionData = async (targetDate?: Date, forceRefresh = false) => { - if (!isLoggedIn) return; - - // 确定要查询的日期 - let derivedDate: Date; - if (targetDate) { - derivedDate = targetDate; - } else { - derivedDate = currentSelectedDate; - } - - const requestKey = getDateKey(derivedDate); - - // 检查是否正在加载或不需要刷新 - if (loadingRef.current.nutrition) { - console.log('营养数据正在加载中,跳过重复请求'); - return; - } - - if (!forceRefresh && !shouldRefreshData(requestKey, 'nutrition')) { - console.log('营养数据缓存未过期,跳过请求'); - return; - } - - try { - loadingRef.current.nutrition = true; - console.log('加载营养数据...', derivedDate); - await dispatch(fetchDailyNutritionData(derivedDate)); - console.log('营养数据加载完成'); - - // 更新缓存时间戳 - updateDataTimestamp(requestKey, 'nutrition'); - - } catch (error) { - console.error('营养数据加载失败:', error); - } finally { - loadingRef.current.nutrition = false; - } - }; // 实际执行数据加载的方法 const executeLoadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => { @@ -304,7 +243,6 @@ export default function ExploreScreen() { console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh); loadHealthData(dateToUse, forceRefresh); if (isLoggedIn) { - loadNutritionData(dateToUse, forceRefresh); loadMoodData(dateToUse, forceRefresh); // 加载喝水数据(只加载今日数据用于后台检查) const isToday = dayjs(dateToUse).isSame(dayjs(), 'day'); @@ -456,10 +394,7 @@ export default function ExploreScreen() { {/* 营养摄入雷达图卡片 */} @@ -470,7 +405,7 @@ export default function ExploreScreen() { {/* 左列 */} {/* 心情卡片 */} - + pushIfAuthedElseLogin('/mood/calendar')} @@ -488,7 +423,7 @@ export default function ExploreScreen() { - + @@ -512,14 +447,14 @@ export default function ExploreScreen() { {/* 右列 */} - + {/* 饮水记录卡片 */} - + + {/* 血氧饱和度卡片 */} - + diff --git a/app/nutrition/records.tsx b/app/nutrition/records.tsx index 4d4ff75..3a22176 100644 --- a/app/nutrition/records.tsx +++ b/app/nutrition/records.tsx @@ -19,6 +19,7 @@ import { selectNutritionSummaryByDate } from '@/store/nutritionSlice'; import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; +import { fetchBasalEnergyBurned } from '@/utils/health'; import { Ionicons } from '@expo/vector-icons'; import { useFocusEffect } from '@react-navigation/native'; import dayjs from 'dayjs'; @@ -73,6 +74,9 @@ export default function NutritionRecordsScreen() { const [hasMoreData, setHasMoreData] = useState(true); const [page, setPage] = useState(1); + // 基础代谢数据状态 + const [basalMetabolism, setBasalMetabolism] = useState(1482); + // 食物添加弹窗状态 const [showFoodOverlay, setShowFoodOverlay] = useState(false); @@ -118,6 +122,7 @@ export default function NutritionRecordsScreen() { // 当选中日期或视图模式变化时重新加载数据 useEffect(() => { + fetchBasalMetabolismData(); if (viewMode === 'daily') { dispatch(fetchDailyNutritionData(currentSelectedDate)); } else { @@ -150,6 +155,22 @@ export default function NutritionRecordsScreen() { } }, [viewMode, currentSelectedDateString, dispatch]); + // 获取基础代谢数据 + const fetchBasalMetabolismData = useCallback(async () => { + try { + const options = { + startDate: dayjs(currentSelectedDate).startOf('day').toDate().toISOString(), + endDate: dayjs(currentSelectedDate).endOf('day').toDate().toISOString() + }; + + const basalEnergy = await fetchBasalEnergyBurned(options); + setBasalMetabolism(basalEnergy || 1482); + } catch (error) { + console.error('获取基础代谢数据失败:', error); + setBasalMetabolism(1482); // 失败时使用默认值 + } + }, [currentSelectedDate]); + const onRefresh = useCallback(async () => { try { setRefreshing(true); @@ -409,7 +430,7 @@ export default function NutritionRecordsScreen() { {/* Calorie Ring Chart */} (null); const [loading, setLoading] = useState(false); + const [modalVisible, setModalVisible] = useState(false); + + // 获取用户基本信息 + const userProfile = useAppSelector(selectUserProfile); + const userAge = useAppSelector(selectUserAge); + + // 计算基础代谢率范围 + const calculateBMRRange = () => { + 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) }; + }; + + const bmrRange = calculateBMRRange(); // 获取基础代谢数据 useEffect(() => { @@ -19,8 +63,12 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard try { setLoading(true); - const data = await fetchHealthDataForDate(selectedDate); - setBasalMetabolism(data?.basalEnergyBurned || null); + const options = { + startDate: dayjs(selectedDate).startOf('day').toDate().toISOString(), + endDate: dayjs(selectedDate).endOf('day').toDate().toISOString() + }; + const basalEnergy = await fetchBasalEnergyBurned(options); + setBasalMetabolism(basalEnergy || null); } catch (error) { console.error('BasalMetabolismCard: 获取基础代谢数据失败:', error); setBasalMetabolism(null); @@ -52,30 +100,115 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard const status = getMetabolismStatus(); return ( - - - {/* 头部区域 */} - - - - 基础代谢 + <> + setModalVisible(true)} + activeOpacity={0.8} + > + {/* 头部区域 */} + + + + 基础代谢 + + + {status.text} + - - {status.text} - - - {/* 数值显示区域 */} - - - {loading ? '加载中...' : (basalMetabolism != null && basalMetabolism > 0 ? Math.round(basalMetabolism).toString() : '--')} - - 千卡/日 - - + {/* 数值显示区域 */} + + + {loading ? '加载中...' : (basalMetabolism != null && basalMetabolism > 0 ? Math.round(basalMetabolism).toString() : '--')} + + 千卡/日 + + + + {/* 基础代谢详情弹窗 */} + 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%) + + + + + ); } @@ -168,4 +301,128 @@ 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/NutritionRadarCard.tsx b/components/NutritionRadarCard.tsx index bc3ed29..6d5736d 100644 --- a/components/NutritionRadarCard.tsx +++ b/components/NutritionRadarCard.tsx @@ -1,7 +1,8 @@ import { AnimatedNumber } from '@/components/AnimatedNumber'; import { ROUTES } from '@/constants/Routes'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; -import { NutritionSummary } from '@/services/dietRecords'; +import { fetchCompleteNutritionCardData, selectNutritionCardDataByDate } from '@/store/nutritionSlice'; import { triggerLightHaptic } from '@/utils/haptics'; import { calculateRemainingCalories } from '@/utils/nutrition'; import dayjs from 'dayjs'; @@ -14,14 +15,8 @@ const AnimatedCircle = Animated.createAnimatedComponent(Circle); export type NutritionRadarCardProps = { - nutritionSummary: NutritionSummary | null; - /** 基础代谢消耗的卡路里 */ - burnedCalories?: number; - /** 基础代谢率 */ - basalMetabolism?: number; - /** 运动消耗卡路里 */ - activeCalories?: number; - + selectedDate?: Date; + style?: object; /** 动画重置令牌 */ resetToken?: number; }; @@ -93,15 +88,40 @@ const SimpleRingProgress = ({ }; export function NutritionRadarCard({ - nutritionSummary, - burnedCalories = 1618, - basalMetabolism, - activeCalories, + selectedDate, + style, resetToken, }: NutritionRadarCardProps) { const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast'); + const [loading, setLoading] = useState(false); - const { pushIfAuthedElseLogin } = useAuthGuard() + const { pushIfAuthedElseLogin } = useAuthGuard(); + const dispatch = useAppDispatch(); + + const dateKey = useMemo(() => { + return selectedDate ? dayjs(selectedDate).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'); + }, [selectedDate]); + + const cardData = useAppSelector(selectNutritionCardDataByDate(dateKey)); + const { nutritionSummary, healthData, basalMetabolism } = cardData; + + // 获取营养和健康数据 + useEffect(() => { + const loadNutritionCardData = async () => { + const targetDate = selectedDate || new Date(); + + try { + setLoading(true); + await dispatch(fetchCompleteNutritionCardData(targetDate)).unwrap(); + } catch (error) { + console.error('NutritionRadarCard: 获取营养卡片数据失败:', error); + } finally { + setLoading(false); + } + }; + + loadNutritionCardData(); + }, [selectedDate, dispatch]); const nutritionStats = useMemo(() => { return [ @@ -117,9 +137,9 @@ export function NutritionRadarCard({ // 计算还能吃的卡路里 const consumedCalories = nutritionSummary?.totalCalories || 0; - // 使用分离的代谢和运动数据,如果没有提供则从burnedCalories推算 - const effectiveBasalMetabolism = basalMetabolism ?? (burnedCalories * 0.7); // 假设70%是基础代谢 - const effectiveActiveCalories = activeCalories ?? (burnedCalories * 0.3); // 假设30%是运动消耗 + // 使用从HealthKit获取的数据,如果没有则使用默认值 + const effectiveBasalMetabolism = basalMetabolism || 0; // 基础代谢默认值 + const effectiveActiveCalories = healthData?.activeCalories || 0; // 运动消耗卡路里 const remainingCalories = calculateRemainingCalories({ basalMetabolism: effectiveBasalMetabolism, @@ -134,7 +154,7 @@ export function NutritionRadarCard({ return ( - + 饮食分析 - 更新: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')} + + {loading ? '加载中...' : `更新: ${dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}`} + @@ -173,10 +195,10 @@ export function NutritionRadarCard({ 还能吃 Math.round(v).toString()} + format={(v) => loading ? '--' : Math.round(v).toString()} /> 千卡 @@ -185,30 +207,30 @@ export function NutritionRadarCard({ 基代 Math.round(v).toString()} + format={(v) => loading ? '--' : Math.round(v).toString()} /> + 运动 Math.round(v).toString()} + format={(v) => loading ? '--' : Math.round(v).toString()} /> - 饮食 Math.round(v).toString()} + format={(v) => loading ? '--' : Math.round(v).toString()} /> diff --git a/components/StepsCard.tsx b/components/StepsCard.tsx index 8311d25..a03efe2 100644 --- a/components/StepsCard.tsx +++ b/components/StepsCard.tsx @@ -1,11 +1,12 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Animated, StyleSheet, Text, TouchableOpacity, View, - ViewStyle + ViewStyle, + InteractionManager } from 'react-native'; import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health'; @@ -33,21 +34,28 @@ const StepsCard: React.FC = ({ const [hourlySteps, setHourSteps] = useState([]) - const getStepData = async (date: Date) => { + const getStepData = useCallback(async (date: Date) => { try { logger.info('获取步数数据...'); - const [steps, hourly] = await Promise.all([ - fetchStepCount(date), - fetchHourlyStepSamples(date) - ]) - setStepCount(steps) - setHourSteps(hourly) + // 先获取步数,立即更新UI + const steps = await fetchStepCount(date); + setStepCount(steps); + + // 使用 InteractionManager 在空闲时获取更复杂的小时数据 + InteractionManager.runAfterInteractions(async () => { + try { + const hourly = await fetchHourlyStepSamples(date); + setHourSteps(hourly); + } catch (error) { + logger.error('获取小时步数数据失败:', error); + } + }); } catch (error) { logger.error('获取步数数据失败:', error); } - } + }, []); useEffect(() => { if (curDate) { @@ -55,55 +63,60 @@ const StepsCard: React.FC = ({ } }, [curDate]); - // 为每个柱体创建独立的动画值 - const animatedValues = useRef( - Array.from({ length: 24 }, () => new Animated.Value(0)) - ).current; + // 优化:减少动画值数量,只为有数据的小时创建动画 + const animatedValues = useRef>(new Map()).current; - // 计算柱状图数据 + // 优化:简化柱状图数据计算,减少计算量 const chartData = useMemo(() => { if (!hourlySteps || hourlySteps.length === 0) { return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 })); } - // 找到最大步数用于计算高度比例 - const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1); - const maxHeight = 20; // 柱状图最大高度(缩小一半) + // 优化:只计算有数据的小时的最大步数 + const activeSteps = hourlySteps.filter(data => data.steps > 0); + if (activeSteps.length === 0) { + return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 })); + } + + const maxSteps = Math.max(...activeSteps.map(data => data.steps)); + const maxHeight = 20; return hourlySteps.map(data => ({ ...data, - height: maxSteps > 0 ? (data.steps / maxSteps) * maxHeight : 0 + height: data.steps > 0 ? (data.steps / maxSteps) * maxHeight : 0 })); }, [hourlySteps]); // 获取当前小时 const currentHour = new Date().getHours(); - // 触发柱体动画 + // 优化:延迟执行动画,减少UI阻塞 useEffect(() => { - // 检查是否有实际数据(不只是空数组) const hasData = chartData && chartData.length > 0 && chartData.some(data => data.steps > 0); if (hasData) { - // 重置所有动画值 - animatedValues.forEach(animValue => animValue.setValue(0)); - - // 使用 setTimeout 确保在下一个事件循环中执行动画,保证组件已完全渲染 - const timeoutId = setTimeout(() => { - // 同时启动所有柱体的弹性动画,有步数的柱体才执行动画 + // 使用 InteractionManager 确保动画不会阻塞用户交互 + InteractionManager.runAfterInteractions(() => { + // 只为有数据的小时创建和执行动画 chartData.forEach((data, index) => { if (data.steps > 0) { - Animated.spring(animatedValues[index], { + // 懒创建动画值 + if (!animatedValues.has(index)) { + animatedValues.set(index, new Animated.Value(0)); + } + + const animValue = animatedValues.get(index)!; + animValue.setValue(0); + + // 使用更高性能的timing动画替代spring + Animated.timing(animValue, { toValue: 1, - tension: 150, - friction: 8, + duration: 300, useNativeDriver: false, }).start(); } }); - }, 50); // 添加小延迟确保渲染完成 - - return () => clearTimeout(timeoutId); + }); } }, [chartData, animatedValues]); @@ -127,17 +140,22 @@ const StepsCard: React.FC = ({ const isActive = data.steps > 0; const isCurrent = index <= currentHour; - // 动画变换:缩放从0到实际高度 - const animatedScale = animatedValues[index].interpolate({ - inputRange: [0, 1], - outputRange: [0, 1], - }); + // 优化:只为有数据的柱体创建动画插值 + const animValue = animatedValues.get(index); + let animatedScale: Animated.AnimatedInterpolation | undefined; + let animatedOpacity: Animated.AnimatedInterpolation | undefined; - // 动画变换:透明度从0到1 - const animatedOpacity = animatedValues[index].interpolate({ - inputRange: [0, 1], - outputRange: [0, 1], - }); + if (animValue && isActive) { + animatedScale = animValue.interpolate({ + inputRange: [0, 1], + outputRange: [0, 1], + }); + + animatedOpacity = animValue.interpolate({ + inputRange: [0, 1], + outputRange: [0, 1], + }); + } return ( @@ -160,8 +178,8 @@ const StepsCard: React.FC = ({ { height: data.height, backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB', - transform: [{ scaleY: animatedScale }], - opacity: animatedOpacity, + transform: animatedScale ? [{ scaleY: animatedScale }] : undefined, + opacity: animatedOpacity || 1, } ]} /> diff --git a/components/StepsCardOptimized.tsx b/components/StepsCardOptimized.tsx new file mode 100644 index 0000000..6b6b180 --- /dev/null +++ b/components/StepsCardOptimized.tsx @@ -0,0 +1,323 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Animated, + StyleSheet, + Text, + TouchableOpacity, + View, + ViewStyle, + InteractionManager +} from 'react-native'; + +import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health'; +import { logger } from '@/utils/logger'; +import dayjs from 'dayjs'; +import { Image } from 'expo-image'; +import { useRouter } from 'expo-router'; +import { AnimatedNumber } from './AnimatedNumber'; + +interface StepsCardProps { + curDate: Date + stepGoal: number; + style?: ViewStyle; +} + +const StepsCardOptimized: React.FC = ({ + curDate, + style, +}) => { + const router = useRouter(); + + const [stepCount, setStepCount] = useState(0) + const [hourlySteps, setHourSteps] = useState([]) + const [isLoading, setIsLoading] = useState(false) + + // 优化:使用debounce减少频繁的数据获取 + const debounceTimer = useRef(null); + + const getStepData = useCallback(async (date: Date) => { + try { + setIsLoading(true); + logger.info('获取步数数据...'); + + // 先获取步数,立即更新UI + const steps = await fetchStepCount(date); + setStepCount(steps); + + // 清除之前的定时器 + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + + // 使用 InteractionManager 在空闲时获取更复杂的小时数据 + InteractionManager.runAfterInteractions(async () => { + try { + const hourly = await fetchHourlyStepSamples(date); + setHourSteps(hourly); + } catch (error) { + logger.error('获取小时步数数据失败:', error); + } finally { + setIsLoading(false); + } + }); + + } catch (error) { + logger.error('获取步数数据失败:', error); + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (curDate) { + getStepData(curDate); + } + }, [curDate, getStepData]); + + // 优化:减少动画值数量,只为有数据的小时创建动画 + const animatedValues = useRef>(new Map()).current; + + // 优化:简化柱状图数据计算,减少计算量 + const chartData = useMemo(() => { + if (!hourlySteps || hourlySteps.length === 0) { + return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 })); + } + + // 优化:只计算有数据的小时的最大步数 + const activeSteps = hourlySteps.filter(data => data.steps > 0); + if (activeSteps.length === 0) { + return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 })); + } + + const maxSteps = Math.max(...activeSteps.map(data => data.steps)); + const maxHeight = 20; + + return hourlySteps.map(data => ({ + ...data, + height: data.steps > 0 ? (data.steps / maxSteps) * maxHeight : 0 + })); + }, [hourlySteps]); + + // 获取当前小时 + const currentHour = new Date().getHours(); + + // 优化:延迟执行动画,减少UI阻塞 + useEffect(() => { + const hasData = chartData && chartData.length > 0 && chartData.some(data => data.steps > 0); + + if (hasData && !isLoading) { + // 使用 InteractionManager 确保动画不会阻塞用户交互 + InteractionManager.runAfterInteractions(() => { + // 只为有数据的小时创建和执行动画 + const animations = chartData + .map((data, index) => { + if (data.steps > 0) { + // 懒创建动画值 + if (!animatedValues.has(index)) { + animatedValues.set(index, new Animated.Value(0)); + } + + const animValue = animatedValues.get(index)!; + animValue.setValue(0); + + // 使用更高性能的timing动画替代spring + return Animated.timing(animValue, { + toValue: 1, + duration: 200, // 减少动画时长 + useNativeDriver: false, + }); + } + return null; + }) + .filter(Boolean) as Animated.CompositeAnimation[]; + + // 批量执行动画,提高性能 + if (animations.length > 0) { + Animated.stagger(50, animations).start(); + } + }); + } + }, [chartData, animatedValues, isLoading]); + + // 优化:使用React.memo包装复杂的渲染组件 + const ChartBars = useMemo(() => { + return chartData.map((data, index) => { + // 判断是否是当前小时或者有活动的小时 + const isActive = data.steps > 0; + const isCurrent = index <= currentHour; + + // 优化:只为有数据的柱体创建动画插值 + const animValue = animatedValues.get(index); + let animatedScale: Animated.AnimatedInterpolation | undefined; + let animatedOpacity: Animated.AnimatedInterpolation | undefined; + + if (animValue && isActive) { + animatedScale = animValue.interpolate({ + inputRange: [0, 1], + outputRange: [0, 1], + }); + + animatedOpacity = animValue.interpolate({ + inputRange: [0, 1], + outputRange: [0, 1], + }); + } + + return ( + + {/* 背景柱体 - 始终显示,使用相似色系的淡色 */} + + + {/* 数据柱体 - 只有当有数据时才显示并执行动画 */} + {isActive && ( + + )} + + ); + }); + }, [chartData, currentHour, animatedValues]); + + const CardContent = () => ( + <> + {/* 标题和步数显示 */} + + + 步数 + {isLoading && 加载中...} + + + {/* 柱状图 */} + + + + {ChartBars} + + + + + {/* 步数和目标显示 */} + + stepCount !== null ? `${Math.round(v)}` : '——'} + resetToken={stepCount} + /> + + + ); + + return ( + { + // 传递当前日期参数到详情页 + const dateParam = dayjs(curDate).format('YYYY-MM-DD'); + router.push(`/steps/detail?date=${dateParam}`); + }} + activeOpacity={0.8} + > + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'space-between', + borderRadius: 20, + padding: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.08, + shadowRadius: 20, + elevation: 8, + }, + header: { + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'center', + }, + titleIcon: { + width: 16, + height: 16, + marginRight: 6, + resizeMode: 'contain', + }, + title: { + fontSize: 14, + color: '#192126', + fontWeight: '600' + }, + loadingText: { + fontSize: 10, + color: '#666', + marginLeft: 8, + }, + chartContainer: { + flex: 1, + justifyContent: 'center', + marginTop: 6 + }, + chartWrapper: { + width: '100%', + alignItems: 'center', + }, + chartArea: { + flexDirection: 'row', + alignItems: 'flex-end', + height: 20, + width: '100%', + maxWidth: 240, + justifyContent: 'space-between', + paddingHorizontal: 4, + }, + barContainer: { + width: 4, + height: 20, + alignItems: 'center', + justifyContent: 'flex-end', + position: 'relative', + }, + chartBar: { + width: 4, + borderRadius: 1, + position: 'absolute', + bottom: 0, + }, + statsContainer: { + alignItems: 'flex-start', + marginTop: 6 + }, + stepCount: { + fontSize: 18, + fontWeight: '600', + color: '#192126', + }, +}); + +export default StepsCardOptimized; \ No newline at end of file diff --git a/components/WaterIntakeCard.tsx b/components/WaterIntakeCard.tsx index 6f7d3f7..0c3492d 100644 --- a/components/WaterIntakeCard.tsx +++ b/components/WaterIntakeCard.tsx @@ -139,7 +139,7 @@ const WaterIntakeCard: React.FC = ({ // 使用用户配置的快速添加饮水量 const waterAmount = quickWaterAmount; // 如果有选中日期,则为该日期添加记录;否则为今天添加记录 - const recordedAt = selectedDate ? dayjs(selectedDate).toISOString() : dayjs().toISOString(); + const recordedAt = dayjs().toISOString() await addWaterRecord(waterAmount, recordedAt); }; diff --git a/ios/OutLive/HealthKitManager.m b/ios/OutLive/HealthKitManager.m index 7c3f747..b9f4339 100644 --- a/ios/OutLive/HealthKitManager.m +++ b/ios/OutLive/HealthKitManager.m @@ -68,4 +68,13 @@ RCT_EXTERN_METHOD(getHourlyStandHours:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolver rejecter:(RCTPromiseRejectBlock)rejecter) +// Water Intake Methods +RCT_EXTERN_METHOD(saveWaterIntakeToHealthKit:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(getWaterIntakeFromHealthKit:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + @end \ No newline at end of file diff --git a/ios/OutLive/HealthKitManager.swift b/ios/OutLive/HealthKitManager.swift index f4e7c8d..7329c09 100644 --- a/ios/OutLive/HealthKitManager.swift +++ b/ios/OutLive/HealthKitManager.swift @@ -36,18 +36,20 @@ class HealthKitManager: NSObject, RCTBridgeModule { static let appleStandTime = HKObjectType.categoryType(forIdentifier: .appleStandHour)! static let oxygenSaturation = HKObjectType.quantityType(forIdentifier: .oxygenSaturation)! static let activitySummary = HKObjectType.activitySummaryType() + static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)! static var all: Set { - return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary] + return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater] } } /// For writing (if needed) private struct WriteTypes { static let bodyMass = HKObjectType.quantityType(forIdentifier: .bodyMass)! + static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)! static var all: Set { - return [bodyMass] + return [bodyMass, dietaryWater] } } @@ -1333,4 +1335,150 @@ class HealthKitManager: NSObject, RCTBridgeModule { healthStore.execute(query) } + // MARK: - Water Intake Methods + + @objc + func saveWaterIntakeToHealthKit( + _ options: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard HKHealthStore.isHealthDataAvailable() else { + rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil) + return + } + + // Parse parameters + guard let amount = options["amount"] as? Double else { + rejecter("INVALID_PARAMETERS", "Amount is required", nil) + return + } + + let recordedAt: Date + if let recordedAtString = options["recordedAt"] as? String, + let date = parseDate(from: recordedAtString) { + recordedAt = date + } else { + recordedAt = Date() + } + + let waterType = WriteTypes.dietaryWater + + // Create quantity sample + let quantity = HKQuantity(unit: HKUnit.literUnit(with: .milli), doubleValue: amount) + let sample = HKQuantitySample( + type: waterType, + quantity: quantity, + start: recordedAt, + end: recordedAt, + metadata: nil + ) + + // Save to HealthKit + healthStore.save(sample) { [weak self] (success, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("SAVE_ERROR", "Failed to save water intake: \(error.localizedDescription)", error) + return + } + + if success { + let result: [String: Any] = [ + "success": true, + "amount": amount, + "recordedAt": self?.dateToISOString(recordedAt) ?? "" + ] + resolver(result) + } else { + rejecter("SAVE_FAILED", "Failed to save water intake", nil) + } + } + } + } + + @objc + func getWaterIntakeFromHealthKit( + _ options: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard HKHealthStore.isHealthDataAvailable() else { + rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil) + return + } + + let waterType = HKObjectType.quantityType(forIdentifier: .dietaryWater)! + + // Parse date range + let startDate: Date + if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { + startDate = d + } else { + startDate = Calendar.current.startOfDay(for: Date()) + } + + let endDate: Date + if let endString = options["endDate"] as? String, let d = parseDate(from: endString) { + endDate = d + } else { + endDate = Date() + } + + let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit + + let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate) + let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) + + let query = HKSampleQuery(sampleType: waterType, + predicate: predicate, + limit: limit, + sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("QUERY_ERROR", "Failed to query water intake: \(error.localizedDescription)", error) + return + } + + guard let waterSamples = samples as? [HKQuantitySample] else { + resolver([ + "data": [], + "totalAmount": 0, + "count": 0, + "startDate": self?.dateToISOString(startDate) ?? "", + "endDate": self?.dateToISOString(endDate) ?? "" + ]) + return + } + + let waterData = waterSamples.map { sample in + [ + "id": sample.uuid.uuidString, + "startDate": self?.dateToISOString(sample.startDate) ?? "", + "endDate": self?.dateToISOString(sample.endDate) ?? "", + "value": sample.quantity.doubleValue(for: HKUnit.literUnit(with: .milli)), + "source": [ + "name": sample.sourceRevision.source.name, + "bundleIdentifier": sample.sourceRevision.source.bundleIdentifier + ], + "metadata": sample.metadata ?? [:] + ] as [String : Any] + } + + let totalAmount = waterSamples.reduce(0.0) { total, sample in + return total + sample.quantity.doubleValue(for: HKUnit.literUnit(with: .milli)) + } + + let result: [String: Any] = [ + "data": waterData, + "totalAmount": totalAmount, + "count": waterData.count, + "startDate": self?.dateToISOString(startDate) ?? "", + "endDate": self?.dateToISOString(endDate) ?? "" + ] + resolver(result) + } + } + healthStore.execute(query) + } + } // end class diff --git a/store/nutritionSlice.ts b/store/nutritionSlice.ts index 342a9f4..fd91271 100644 --- a/store/nutritionSlice.ts +++ b/store/nutritionSlice.ts @@ -1,4 +1,5 @@ import { calculateNutritionSummary, deleteDietRecord, DietRecord, getDietRecords, NutritionSummary } from '@/services/dietRecords'; +import { fetchBasalEnergyBurned, fetchHealthDataForDate, TodayHealthData } from '@/utils/health'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import dayjs from 'dayjs'; @@ -10,10 +11,18 @@ export interface NutritionState { // 按日期存储的营养摘要 summaryByDate: Record; + // 按日期存储的健康数据(基础代谢、运动消耗等) + healthDataByDate: Record; + + // 按日期存储的基础代谢数据 + basalMetabolismByDate: Record; + // 加载状态 loading: { records: boolean; delete: boolean; + healthData: boolean; + basalMetabolism: boolean; }; // 错误信息 @@ -35,9 +44,13 @@ export interface NutritionState { const initialState: NutritionState = { recordsByDate: {}, summaryByDate: {}, + healthDataByDate: {}, + basalMetabolismByDate: {}, loading: { records: false, delete: false, + healthData: false, + basalMetabolism: false, }, error: null, pagination: { @@ -126,6 +139,74 @@ export const fetchDailyNutritionData = createAsyncThunk( } ); +// 异步操作:获取指定日期的健康数据 +export const fetchDailyHealthData = createAsyncThunk( + 'nutrition/fetchHealthData', + async (date: Date, { rejectWithValue }) => { + try { + const dateString = dayjs(date).format('YYYY-MM-DD'); + const healthData = await fetchHealthDataForDate(date); + + return { + dateKey: dateString, + healthData, + }; + } catch (error: any) { + return rejectWithValue(error.message || '获取健康数据失败'); + } + } +); + +// 异步操作:获取指定日期的基础代谢数据 +export const fetchDailyBasalMetabolism = createAsyncThunk( + 'nutrition/fetchBasalMetabolism', + async (date: Date, { rejectWithValue }) => { + try { + const dateString = dayjs(date).format('YYYY-MM-DD'); + const startDate = dayjs(date).startOf('day').toISOString(); + const endDate = dayjs(date).endOf('day').toISOString(); + + const basalMetabolism = await fetchBasalEnergyBurned({ + startDate, + endDate, + }); + + return { + dateKey: dateString, + basalMetabolism, + }; + } catch (error: any) { + return rejectWithValue(error.message || '获取基础代谢数据失败'); + } + } +); + +// 异步操作:获取指定日期的完整营养卡片数据(营养数据 + 健康数据) +export const fetchCompleteNutritionCardData = createAsyncThunk( + 'nutrition/fetchCompleteCardData', + async (date: Date, { rejectWithValue, dispatch }) => { + try { + const dateString = dayjs(date).format('YYYY-MM-DD'); + + // 并行获取营养数据和健康数据 + const [nutritionResult, healthResult, basalResult] = await Promise.allSettled([ + dispatch(fetchDailyNutritionData(date)).unwrap(), + dispatch(fetchDailyHealthData(date)).unwrap(), + dispatch(fetchDailyBasalMetabolism(date)).unwrap(), + ]); + + return { + dateKey: dateString, + nutritionSuccess: nutritionResult.status === 'fulfilled', + healthSuccess: healthResult.status === 'fulfilled', + basalSuccess: basalResult.status === 'fulfilled', + }; + } catch (error: any) { + return rejectWithValue(error.message || '获取完整营养卡片数据失败'); + } + } +); + const nutritionSlice = createSlice({ name: 'nutrition', initialState, @@ -140,12 +221,16 @@ const nutritionSlice = createSlice({ const dateKey = action.payload; delete state.recordsByDate[dateKey]; delete state.summaryByDate[dateKey]; + delete state.healthDataByDate[dateKey]; + delete state.basalMetabolismByDate[dateKey]; }, // 清除所有数据 clearAllData: (state) => { state.recordsByDate = {}; state.summaryByDate = {}; + state.healthDataByDate = {}; + state.basalMetabolismByDate = {}; state.error = null; state.lastUpdateTime = null; state.pagination = initialState.pagination; @@ -258,6 +343,61 @@ const nutritionSlice = createSlice({ state.loading.records = false; state.error = action.payload as string; }); + + // fetchDailyHealthData + builder + .addCase(fetchDailyHealthData.pending, (state) => { + state.loading.healthData = true; + state.error = null; + }) + .addCase(fetchDailyHealthData.fulfilled, (state, action) => { + state.loading.healthData = false; + const { dateKey, healthData } = action.payload; + state.healthDataByDate[dateKey] = healthData; + state.lastUpdateTime = new Date().toISOString(); + }) + .addCase(fetchDailyHealthData.rejected, (state, action) => { + state.loading.healthData = false; + state.error = action.payload as string; + }); + + // fetchDailyBasalMetabolism + builder + .addCase(fetchDailyBasalMetabolism.pending, (state) => { + state.loading.basalMetabolism = true; + state.error = null; + }) + .addCase(fetchDailyBasalMetabolism.fulfilled, (state, action) => { + state.loading.basalMetabolism = false; + const { dateKey, basalMetabolism } = action.payload; + state.basalMetabolismByDate[dateKey] = basalMetabolism; + state.lastUpdateTime = new Date().toISOString(); + }) + .addCase(fetchDailyBasalMetabolism.rejected, (state, action) => { + state.loading.basalMetabolism = false; + state.error = action.payload as string; + }); + + // fetchCompleteNutritionCardData + builder + .addCase(fetchCompleteNutritionCardData.pending, (state) => { + state.loading.records = true; + state.loading.healthData = true; + state.loading.basalMetabolism = true; + state.error = null; + }) + .addCase(fetchCompleteNutritionCardData.fulfilled, (state, action) => { + state.loading.records = false; + state.loading.healthData = false; + state.loading.basalMetabolism = false; + state.lastUpdateTime = new Date().toISOString(); + }) + .addCase(fetchCompleteNutritionCardData.rejected, (state, action) => { + state.loading.records = false; + state.loading.healthData = false; + state.loading.basalMetabolism = false; + state.error = action.payload as string; + }); }, }); @@ -276,6 +416,12 @@ export const selectNutritionRecordsByDate = (dateKey: string) => (state: { nutri export const selectNutritionSummaryByDate = (dateKey: string) => (state: { nutrition: NutritionState }) => state.nutrition.summaryByDate[dateKey] || null; +export const selectHealthDataByDate = (dateKey: string) => (state: { nutrition: NutritionState }) => + state.nutrition.healthDataByDate[dateKey] || null; + +export const selectBasalMetabolismByDate = (dateKey: string) => (state: { nutrition: NutritionState }) => + state.nutrition.basalMetabolismByDate[dateKey] || 0; + export const selectNutritionLoading = (state: { nutrition: NutritionState }) => state.nutrition.loading; @@ -285,4 +431,13 @@ export const selectNutritionError = (state: { nutrition: NutritionState }) => export const selectNutritionPagination = (state: { nutrition: NutritionState }) => state.nutrition.pagination; +// 复合选择器:获取指定日期的完整营养卡片数据 +export const selectNutritionCardDataByDate = (dateKey: string) => (state: { nutrition: NutritionState }) => ({ + nutritionSummary: state.nutrition.summaryByDate[dateKey] || null, + healthData: state.nutrition.healthDataByDate[dateKey] || null, + basalMetabolism: state.nutrition.basalMetabolismByDate[dateKey] || 0, + loading: state.nutrition.loading, + error: state.nutrition.error, +}); + export default nutritionSlice.reducer; \ No newline at end of file diff --git a/utils/health.ts b/utils/health.ts index 81cdb48..f152222 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -309,7 +309,7 @@ export async function fetchStepCount(date: Date): Promise { } -// 使用样本数据获取每小时步数 +// 使用样本数据获取每小时步数 - 优化版本,减少计算复杂度 export async function fetchHourlyStepSamples(date: Date): Promise { try { const options = createDateRange(date); @@ -318,22 +318,25 @@ export async function fetchHourlyStepSamples(date: Date): Promise ({ - hour: i, - steps: 0 - })); + // 优化:使用更高效的数据结构 + const hourlyMap = new Map(); - // 将每小时的步数样本数据映射到对应的小时 + // 优化:批量处理数据,减少重复验证 result.data.forEach((sample: any) => { - if (sample && sample.hour !== undefined && sample.value !== undefined) { - const hour = sample.hour; - if (hour >= 0 && hour < 24) { - hourlyData[hour].steps = Math.round(sample.value); - } + if (sample?.hour >= 0 && sample?.hour < 24 && sample?.value !== undefined) { + hourlyMap.set(sample.hour, Math.round(sample.value)); } }); + // 生成最终数组 + const hourlyData: HourlyStepData[] = []; + for (let i = 0; i < 24; i++) { + hourlyData.push({ + hour: i, + steps: hourlyMap.get(i) || 0 + }); + } + return hourlyData; } else { logWarning('每小时步数', '为空或格式错误'); @@ -467,7 +470,7 @@ async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise { +export async function fetchBasalEnergyBurned(options: HealthDataOptions): Promise { try { const result = await HealthKitManager.getBasalEnergyBurned(options); @@ -765,24 +768,45 @@ export async function testOxygenSaturationData(_date: Date = dayjs().toDate()): } } -// 添加饮水记录到 HealthKit (暂未实现) -export async function saveWaterIntakeToHealthKit(_amount: number, _recordedAt?: string): Promise { +// 添加饮水记录到 HealthKit +export async function saveWaterIntakeToHealthKit(amount: number, recordedAt?: string): Promise { try { - // Note: Water intake saving would need to be implemented in native module - console.log('饮水记录保存到HealthKit暂未实现'); - return true; // Return true for now to not break existing functionality + console.log('开始保存饮水记录到HealthKit...', { amount, recordedAt }); + + const options = { + amount: amount, + recordedAt: recordedAt || new Date().toISOString() + }; + + const result = await HealthKitManager.saveWaterIntakeToHealthKit(options); + + if (result && result.success) { + console.log('饮水记录保存成功:', result); + return true; + } else { + console.error('饮水记录保存失败:', result); + return false; + } } catch (error) { console.error('添加饮水记录到 HealthKit 失败:', error); return false; } } -// 获取 HealthKit 中的饮水记录 (暂未实现) -export async function getWaterIntakeFromHealthKit(_options: HealthDataOptions): Promise { +// 获取 HealthKit 中的饮水记录 +export async function getWaterIntakeFromHealthKit(options: HealthDataOptions): Promise { try { - // Note: Water intake fetching would need to be implemented in native module - console.log('从HealthKit获取饮水记录暂未实现'); - return []; + console.log('开始从HealthKit获取饮水记录...', options); + + const result = await HealthKitManager.getWaterIntakeFromHealthKit(options); + + if (result && result.data && Array.isArray(result.data)) { + console.log('成功获取饮水记录:', result); + return result.data; + } else { + console.log('饮水记录为空或格式错误:', result); + return []; + } } catch (error) { console.error('获取 HealthKit 饮水记录失败:', error); return [];