feat: 更新健康数据管理功能及相关组件
- 新增 healthSlice,用于管理健康数据的 Redux 状态 - 在 Statistics 组件中整合健康数据获取逻辑,优化数据展示 - 更新 NutritionRadarCard 组件,调整卡路里计算区域,提升用户体验 - 移除不必要的状态管理,简化组件逻辑 - 优化代码结构,提升可读性和维护性
This commit is contained in:
@@ -14,8 +14,8 @@ import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useBackgroundTasks } from '@/hooks/useBackgroundTasks';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords';
|
||||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
||||
@@ -85,11 +85,7 @@ const FloatingCard = ({ children, delay = 0, style }: {
|
||||
};
|
||||
|
||||
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 userProfile = useAppSelector((s) => s.user.profile);
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||
|
||||
@@ -101,28 +97,46 @@ export default function ExploreScreen() {
|
||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||
}, [tabBarHeight, insets?.bottom]);
|
||||
|
||||
// HealthKit: 每次页面聚焦都拉取今日数据
|
||||
const [stepCount, setStepCount] = useState<number | null>(null);
|
||||
const [activeCalories, setActiveCalories] = useState<number | null>(null);
|
||||
// 基础代谢率(千卡)
|
||||
const [basalMetabolism, setBasalMetabolism] = useState<number | null>(null);
|
||||
// 睡眠时长(分钟)
|
||||
const [sleepDuration, setSleepDuration] = useState<number | null>(null);
|
||||
// HRV数据
|
||||
const [hrvValue, setHrvValue] = useState<number>(0);
|
||||
const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date());
|
||||
// 健身圆环数据
|
||||
const [fitnessRingsData, setFitnessRingsData] = useState({
|
||||
// 获取当前选中日期
|
||||
const getCurrentSelectedDate = () => {
|
||||
const days = getMonthDaysZh();
|
||||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
};
|
||||
|
||||
|
||||
// 获取当前选中日期
|
||||
const currentSelectedDate = getCurrentSelectedDate();
|
||||
const currentSelectedDateString = dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
|
||||
// 从 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,
|
||||
activeCaloriesGoal: 350,
|
||||
exerciseMinutes: 0,
|
||||
exerciseMinutesGoal: 30,
|
||||
standHours: 0,
|
||||
standHoursGoal: 12
|
||||
});
|
||||
// 血氧饱和度和心率数据
|
||||
const [oxygenSaturation, setOxygenSaturation] = useState<number | null>(null);
|
||||
const [heartRate, setHeartRate] = useState<number | null>(null);
|
||||
standHoursGoal: 12,
|
||||
};
|
||||
|
||||
// HRV更新时间
|
||||
const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date());
|
||||
|
||||
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||
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 getCurrentSelectedDate = () => {
|
||||
const days = getMonthDaysZh();
|
||||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
};
|
||||
|
||||
// 从 Redux 获取当前日期的心情记录
|
||||
const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate(
|
||||
dayjs(getCurrentSelectedDate()).format('YYYY-MM-DD')
|
||||
currentSelectedDateString
|
||||
));
|
||||
|
||||
// 加载心情数据
|
||||
@@ -205,33 +214,17 @@ export default function ExploreScreen() {
|
||||
console.log('设置UI状态:', data);
|
||||
// 仅当该请求仍是最新时,才应用结果
|
||||
if (latestRequestKeyRef.current === requestKey) {
|
||||
setStepCount(data.steps);
|
||||
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 dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||||
|
||||
const hrv = data.hrv ?? 0;
|
||||
setHrvValue(hrv);
|
||||
// 使用 Redux 存储健康数据
|
||||
dispatch(setHealthData({
|
||||
date: dateString,
|
||||
data: data
|
||||
}));
|
||||
|
||||
// 更新HRV数据时间
|
||||
setHrvUpdateTime(new Date());
|
||||
|
||||
// 设置血氧饱和度和心率数据
|
||||
setOxygenSaturation(data.oxygenSaturation ?? null);
|
||||
setHeartRate(data.heartRate ?? null);
|
||||
|
||||
console.log('血氧饱和度数据:', data.oxygenSaturation);
|
||||
console.log('心率数据:', data.heartRate);
|
||||
|
||||
setAnimToken((t) => t + 1);
|
||||
} else {
|
||||
console.log('忽略过期健康数据请求结果,key=', requestKey, '最新key=', latestRequestKeyRef.current);
|
||||
@@ -240,9 +233,6 @@ export default function ExploreScreen() {
|
||||
|
||||
} catch (error) {
|
||||
console.error('HealthKit流程出现异常:', error);
|
||||
// 重置血氧饱和度和心率数据
|
||||
setOxygenSaturation(null);
|
||||
setHeartRate(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -281,7 +271,7 @@ export default function ExploreScreen() {
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
|
||||
const currentDate = getCurrentSelectedDate();
|
||||
const currentDate = currentSelectedDate;
|
||||
if (currentDate) {
|
||||
loadHealthData(currentDate);
|
||||
if (isLoggedIn) {
|
||||
@@ -317,6 +307,7 @@ export default function ExploreScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 背景渐变 */}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { NutritionSummary } from '@/services/dietRecords';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Feather from '@expo/vector-icons/Feather';
|
||||
import dayjs from 'dayjs';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useMemo } from 'react';
|
||||
@@ -117,8 +116,10 @@ export function NutritionRadarCard({
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>营养摄入分析</Text>
|
||||
<View style={styles.cardRightContainer}>
|
||||
<Text style={styles.cardSubtitle}>更新: {dayjs(nutritionSummary?.updatedAt).format('YYYY-MM-DD HH:mm')}</Text>
|
||||
<Feather name="more-vertical" size={16} color="#9AA3AE" />
|
||||
<Text style={styles.cardSubtitle}>更新: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text>
|
||||
<View style={styles.addButton}>
|
||||
<Ionicons name="add" size={12} color="#FFFFFF" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -146,8 +147,8 @@ export function NutritionRadarCard({
|
||||
{/* 卡路里计算区域 */}
|
||||
<View style={styles.calorieSection}>
|
||||
<View style={styles.calorieContent}>
|
||||
<Text style={styles.calorieSubtitle}>还能吃(千卡)</Text>
|
||||
<View style={styles.calculationRow}>
|
||||
<Text style={styles.calorieSubtitle}>还能吃(千卡)</Text>
|
||||
<AnimatedNumber
|
||||
value={remainingCalories}
|
||||
resetToken={resetToken}
|
||||
@@ -176,39 +177,8 @@ export function NutritionRadarCard({
|
||||
style={styles.calculationValue}
|
||||
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 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>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
@@ -310,8 +280,8 @@ const styles = StyleSheet.create({
|
||||
calorieSubtitle: {
|
||||
fontSize: 10,
|
||||
color: '#64748B',
|
||||
marginBottom: 8,
|
||||
fontWeight: '600',
|
||||
marginRight: 4,
|
||||
},
|
||||
calculationRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -363,9 +333,6 @@ const styles = StyleSheet.create({
|
||||
fontSize: 24,
|
||||
},
|
||||
addButton: {
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: -2,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
|
||||
126
store/healthSlice.ts
Normal file
126
store/healthSlice.ts
Normal 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;
|
||||
@@ -3,6 +3,7 @@ import challengeReducer from './challengeSlice';
|
||||
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
||||
import exerciseLibraryReducer from './exerciseLibrarySlice';
|
||||
import goalsReducer from './goalsSlice';
|
||||
import healthReducer from './healthSlice';
|
||||
import moodReducer from './moodSlice';
|
||||
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
||||
import tasksReducer from './tasksSlice';
|
||||
@@ -44,6 +45,7 @@ export const store = configureStore({
|
||||
challenge: challengeReducer,
|
||||
checkin: checkinReducer,
|
||||
goals: goalsReducer,
|
||||
health: healthReducer,
|
||||
mood: moodReducer,
|
||||
tasks: tasksReducer,
|
||||
trainingPlan: trainingPlanReducer,
|
||||
|
||||
Reference in New Issue
Block a user