feat: 更新健康数据管理功能及相关组件

- 新增 healthSlice,用于管理健康数据的 Redux 状态
- 在 Statistics 组件中整合健康数据获取逻辑,优化数据展示
- 更新 NutritionRadarCard 组件,调整卡路里计算区域,提升用户体验
- 移除不必要的状态管理,简化组件逻辑
- 优化代码结构,提升可读性和维护性
This commit is contained in:
richarjiang
2025-08-25 19:20:56 +08:00
parent 91b7b0cb99
commit e6bbda9d0f
4 changed files with 179 additions and 93 deletions

View File

@@ -14,8 +14,8 @@ import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useBackgroundTasks } from '@/hooks/useBackgroundTasks'; import { useBackgroundTasks } from '@/hooks/useBackgroundTasks';
import { useColorScheme } from '@/hooks/useColorScheme';
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords'; import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health'; import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
@@ -85,11 +85,7 @@ const FloatingCard = ({ children, delay = 0, style }: {
}; };
export default function ExploreScreen() { export default function ExploreScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000; const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const userProfile = useAppSelector((s) => s.user.profile);
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard(); const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
@@ -101,28 +97,46 @@ export default function ExploreScreen() {
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0); return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]); }, [tabBarHeight, insets?.bottom]);
// HealthKit: 每次页面聚焦都拉取今日数据 // 获取当前选中日期
const [stepCount, setStepCount] = useState<number | null>(null); const getCurrentSelectedDate = () => {
const [activeCalories, setActiveCalories] = useState<number | null>(null); const days = getMonthDaysZh();
// 基础代谢率(千卡) return days[selectedIndex]?.date?.toDate() ?? new Date();
const [basalMetabolism, setBasalMetabolism] = useState<number | null>(null); };
// 睡眠时长(分钟)
const [sleepDuration, setSleepDuration] = useState<number | null>(null);
// HRV数据 // 获取当前选中日期
const [hrvValue, setHrvValue] = useState<number>(0); const currentSelectedDate = getCurrentSelectedDate();
const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date()); const currentSelectedDateString = dayjs(currentSelectedDate).format('YYYY-MM-DD');
// 健身圆环数据
const [fitnessRingsData, setFitnessRingsData] = useState({ // 从 Redux 获取指定日期的健康数据
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
// 解构健康数据
const stepCount = healthData?.steps ?? null;
const activeCalories = healthData?.activeEnergyBurned ?? null;
const basalMetabolism = healthData?.basalEnergyBurned ?? null;
const sleepDuration = healthData?.sleepDuration ?? null;
const hrvValue = healthData?.hrv ?? 0;
const oxygenSaturation = healthData?.oxygenSaturation ?? null;
const heartRate = healthData?.heartRate ?? null;
const fitnessRingsData = healthData ? {
activeCalories: healthData.activeEnergyBurned,
activeCaloriesGoal: healthData.activeCaloriesGoal,
exerciseMinutes: healthData.exerciseMinutes,
exerciseMinutesGoal: healthData.exerciseMinutesGoal,
standHours: healthData.standHours,
standHoursGoal: healthData.standHoursGoal,
} : {
activeCalories: 0, activeCalories: 0,
activeCaloriesGoal: 350, activeCaloriesGoal: 350,
exerciseMinutes: 0, exerciseMinutes: 0,
exerciseMinutesGoal: 30, exerciseMinutesGoal: 30,
standHours: 0, standHours: 0,
standHoursGoal: 12 standHoursGoal: 12,
}); };
// 血氧饱和度和心率数据
const [oxygenSaturation, setOxygenSaturation] = useState<number | null>(null); // HRV更新时间
const [heartRate, setHeartRate] = useState<number | null>(null); const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date());
// 用于触发动画重置的 token当日期或数据变化时更新 // 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0); const [animToken, setAnimToken] = useState(0);
@@ -140,15 +154,10 @@ export default function ExploreScreen() {
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`; const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
// 获取当前选中日期
const getCurrentSelectedDate = () => {
const days = getMonthDaysZh();
return days[selectedIndex]?.date?.toDate() ?? new Date();
};
// 从 Redux 获取当前日期的心情记录 // 从 Redux 获取当前日期的心情记录
const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate( const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate(
dayjs(getCurrentSelectedDate()).format('YYYY-MM-DD') currentSelectedDateString
)); ));
// 加载心情数据 // 加载心情数据
@@ -205,33 +214,17 @@ export default function ExploreScreen() {
console.log('设置UI状态:', data); console.log('设置UI状态:', data);
// 仅当该请求仍是最新时,才应用结果 // 仅当该请求仍是最新时,才应用结果
if (latestRequestKeyRef.current === requestKey) { if (latestRequestKeyRef.current === requestKey) {
setStepCount(data.steps); const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
setActiveCalories(Math.round(data.activeEnergyBurned));
setBasalMetabolism(Math.round(data.basalEnergyBurned));
setSleepDuration(data.sleepDuration);
// 更新健身圆环数据
setFitnessRingsData({
activeCalories: data.activeEnergyBurned,
activeCaloriesGoal: data.activeCaloriesGoal,
exerciseMinutes: data.exerciseMinutes,
exerciseMinutesGoal: data.exerciseMinutesGoal,
standHours: data.standHours,
standHoursGoal: data.standHoursGoal
});
const hrv = data.hrv ?? 0; // 使用 Redux 存储健康数据
setHrvValue(hrv); dispatch(setHealthData({
date: dateString,
data: data
}));
// 更新HRV数据时间 // 更新HRV数据时间
setHrvUpdateTime(new Date()); setHrvUpdateTime(new Date());
// 设置血氧饱和度和心率数据
setOxygenSaturation(data.oxygenSaturation ?? null);
setHeartRate(data.heartRate ?? null);
console.log('血氧饱和度数据:', data.oxygenSaturation);
console.log('心率数据:', data.heartRate);
setAnimToken((t) => t + 1); setAnimToken((t) => t + 1);
} else { } else {
console.log('忽略过期健康数据请求结果key=', requestKey, '最新key=', latestRequestKeyRef.current); console.log('忽略过期健康数据请求结果key=', requestKey, '最新key=', latestRequestKeyRef.current);
@@ -240,9 +233,6 @@ export default function ExploreScreen() {
} catch (error) { } catch (error) {
console.error('HealthKit流程出现异常:', error); console.error('HealthKit流程出现异常:', error);
// 重置血氧饱和度和心率数据
setOxygenSaturation(null);
setHeartRate(null);
} }
}; };
@@ -281,7 +271,7 @@ export default function ExploreScreen() {
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致 // 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
const currentDate = getCurrentSelectedDate(); const currentDate = currentSelectedDate;
if (currentDate) { if (currentDate) {
loadHealthData(currentDate); loadHealthData(currentDate);
if (isLoggedIn) { if (isLoggedIn) {
@@ -317,6 +307,7 @@ export default function ExploreScreen() {
} }
}; };
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* 背景渐变 */} {/* 背景渐变 */}

View File

@@ -2,7 +2,6 @@ import { AnimatedNumber } from '@/components/AnimatedNumber';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { NutritionSummary } from '@/services/dietRecords'; import { NutritionSummary } from '@/services/dietRecords';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import Feather from '@expo/vector-icons/Feather';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
@@ -117,8 +116,10 @@ export function NutritionRadarCard({
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}></Text>
<View style={styles.cardRightContainer}> <View style={styles.cardRightContainer}>
<Text style={styles.cardSubtitle}>: {dayjs(nutritionSummary?.updatedAt).format('YYYY-MM-DD HH:mm')}</Text> <Text style={styles.cardSubtitle}>: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text>
<Feather name="more-vertical" size={16} color="#9AA3AE" /> <View style={styles.addButton}>
<Ionicons name="add" size={12} color="#FFFFFF" />
</View>
</View> </View>
</View> </View>
@@ -146,8 +147,8 @@ export function NutritionRadarCard({
{/* 卡路里计算区域 */} {/* 卡路里计算区域 */}
<View style={styles.calorieSection}> <View style={styles.calorieSection}>
<View style={styles.calorieContent}> <View style={styles.calorieContent}>
<Text style={styles.calorieSubtitle}>()</Text>
<View style={styles.calculationRow}> <View style={styles.calculationRow}>
<Text style={styles.calorieSubtitle}>()</Text>
<AnimatedNumber <AnimatedNumber
value={remainingCalories} value={remainingCalories}
resetToken={resetToken} resetToken={resetToken}
@@ -176,39 +177,8 @@ export function NutritionRadarCard({
style={styles.calculationValue} style={styles.calculationValue}
format={(v) => Math.round(v).toString()} format={(v) => Math.round(v).toString()}
/> />
<Text style={styles.calculationText}> - </Text>
<View style={styles.calculationItem}>
<Ionicons name="trending-down" size={16} color="#95A5A6" />
<Text style={styles.calculationLabel}></Text>
</View>
<AnimatedNumber
value={calorieDeficit}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => Math.round(v).toString()}
/>
</View> </View>
</View> </View>
{/* 餐次选择区域 */}
{/* <View style={styles.mealsContainer}>
{meals.map((meal) => (
<TouchableOpacity
key={meal.type}
style={styles.mealItem}
onPress={() => onMealPress?.(meal.type)}
activeOpacity={0.7}
>
<View style={styles.mealIconContainer}>
<Text style={styles.mealEmoji}>{meal.emoji}</Text>
<View style={styles.addButton}>
<Ionicons name="add" size={12} color="#FFFFFF" />
</View>
</View>
<Text style={styles.mealName}>{meal.name}</Text>
</TouchableOpacity>
))}
</View> */}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );
@@ -310,8 +280,8 @@ const styles = StyleSheet.create({
calorieSubtitle: { calorieSubtitle: {
fontSize: 10, fontSize: 10,
color: '#64748B', color: '#64748B',
marginBottom: 8,
fontWeight: '600', fontWeight: '600',
marginRight: 4,
}, },
calculationRow: { calculationRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -363,9 +333,6 @@ const styles = StyleSheet.create({
fontSize: 24, fontSize: 24,
}, },
addButton: { addButton: {
position: 'absolute',
top: -2,
right: -2,
width: 16, width: 16,
height: 16, height: 16,
borderRadius: 8, borderRadius: 8,

126
store/healthSlice.ts Normal file
View File

@@ -0,0 +1,126 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { AppDispatch, RootState } from './index';
// 健康数据类型定义
export interface FitnessRingsData {
activeCalories: number;
activeCaloriesGoal: number;
exerciseMinutes: number;
exerciseMinutesGoal: number;
standHours: number;
standHoursGoal: number;
}
export interface HealthData {
steps: number | null;
activeCalories: number | null;
basalEnergyBurned: number | null;
sleepDuration: number | null;
hrv: number | null;
oxygenSaturation: number | null;
heartRate: number | null;
activeEnergyBurned: number;
activeCaloriesGoal: number;
exerciseMinutes: number;
exerciseMinutesGoal: number;
standHours: number;
standHoursGoal: number;
}
export interface HealthState {
// 按日期存储的历史数据
dataByDate: Record<string, HealthData>;
// 加载状态
loading: boolean;
error: string | null;
// 最后更新时间
lastUpdateTime: string | null;
}
// 初始状态
const initialState: HealthState = {
dataByDate: {},
loading: false,
error: null,
lastUpdateTime: null,
};
const healthSlice = createSlice({
name: 'health',
initialState,
reducers: {
// 设置加载状态
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
// 设置错误信息
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
},
// 设置完整的健康数据
setHealthData: (state, action: PayloadAction<{
date: string;
data: HealthData;
}>) => {
const { date, data } = action.payload;
// 存储到历史数据
state.dataByDate[date] = data;
state.lastUpdateTime = new Date().toISOString();
},
// 清除特定日期的数据
clearHealthDataForDate: (state, action: PayloadAction<string>) => {
const date = action.payload;
delete state.dataByDate[date];
},
// 清除所有健康数据
clearAllHealthData: (state) => {
state.dataByDate = {};
state.error = null;
state.lastUpdateTime = null;
},
},
});
// Action creators
export const {
setLoading,
setError,
setHealthData,
clearHealthDataForDate,
clearAllHealthData,
} = healthSlice.actions;
// Thunk action to fetch and set health data for a specific date
export const fetchHealthDataForDate = (date: Date) => {
return async (dispatch: AppDispatch, getState: () => RootState) => {
try {
dispatch(setLoading(true));
dispatch(setError(null));
// 这里可以添加实际的 API 调用逻辑
// 目前我们假设数据已经通过其他方式获取
dispatch(setLoading(false));
} catch (error) {
dispatch(setError(error instanceof Error ? error.message : '获取健康数据失败'));
dispatch(setLoading(false));
}
};
};
// Selectors
export const selectHealthDataByDate = (date: string) => (state: RootState) => state.health.dataByDate[date];
export const selectHealthLoading = (state: RootState) => state.health.loading;
export const selectHealthError = (state: RootState) => state.health.error;
export const selectLastUpdateTime = (state: RootState) => state.health.lastUpdateTime;
export default healthSlice.reducer;

View File

@@ -3,6 +3,7 @@ import challengeReducer from './challengeSlice';
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice'; import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
import exerciseLibraryReducer from './exerciseLibrarySlice'; import exerciseLibraryReducer from './exerciseLibrarySlice';
import goalsReducer from './goalsSlice'; import goalsReducer from './goalsSlice';
import healthReducer from './healthSlice';
import moodReducer from './moodSlice'; import moodReducer from './moodSlice';
import scheduleExerciseReducer from './scheduleExerciseSlice'; import scheduleExerciseReducer from './scheduleExerciseSlice';
import tasksReducer from './tasksSlice'; import tasksReducer from './tasksSlice';
@@ -44,6 +45,7 @@ export const store = configureStore({
challenge: challengeReducer, challenge: challengeReducer,
checkin: checkinReducer, checkin: checkinReducer,
goals: goalsReducer, goals: goalsReducer,
health: healthReducer,
mood: moodReducer, mood: moodReducer,
tasks: tasksReducer, tasks: tasksReducer,
trainingPlan: trainingPlanReducer, trainingPlan: trainingPlanReducer,