- 从 healthSlice 和 health.ts 中移除 sleepDuration 字段及相关获取逻辑 - 将 SleepCard 改为按需异步获取睡眠数据,支持传入指定日期 - 睡眠详情页改为通过路由参数接收日期,支持查看历史记录 - 移除 statistics 页面对 sleepDuration 的直接依赖,统一由 SleepCard 管理 - 删除未使用的 SleepStageChart 组件,简化页面结构
1005 lines
29 KiB
TypeScript
1005 lines
29 KiB
TypeScript
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
|
||
import { DateSelector } from '@/components/DateSelector';
|
||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||
import { MoodCard } from '@/components/MoodCard';
|
||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||
import SleepCard from '@/components/statistic/SleepCard';
|
||
import StepsCard from '@/components/StepsCard';
|
||
import { StressMeter } from '@/components/StressMeter';
|
||
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
||
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||
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 { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||
import { getTestHealthData } from '@/utils/mockHealthData';
|
||
import { calculateNutritionGoals } from '@/utils/nutrition';
|
||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||
import dayjs from 'dayjs';
|
||
import { LinearGradient } from 'expo-linear-gradient';
|
||
import { debounce } from 'lodash';
|
||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
AppState,
|
||
Image,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Text,
|
||
TouchableOpacity,
|
||
View
|
||
} from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
// 浮动动画组件
|
||
const FloatingCard = ({ children, delay = 0, style }: {
|
||
children: React.ReactNode;
|
||
delay?: number;
|
||
style?: any;
|
||
}) => {
|
||
|
||
|
||
return (
|
||
<View
|
||
style={[
|
||
style,
|
||
{
|
||
marginBottom: 8,
|
||
},
|
||
]}
|
||
>
|
||
{children}
|
||
</View>
|
||
);
|
||
};
|
||
|
||
export default function ExploreScreen() {
|
||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||
const userProfile = useAppSelector((s) => s.user.profile);
|
||
|
||
// å¼€å<E282AC>‘调试:设置为trueæ<65>¥ä½¿ç”¨mockæ•°æ<C2B0>®
|
||
// 在真机测试时,å<C592>¯ä»¥æš‚时设置为trueæ<65>¥éªŒè¯<C3A8>组件显示逻辑
|
||
const useMockData = __DEV__ || false; // 改为trueæ<65>¥å<C2A5>¯ç”¨mockæ•°æ<C2B0>®è°ƒè¯•
|
||
|
||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||
|
||
// 使用 dayjs:当月日期与默认选ä¸"今天"
|
||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||
const tabBarHeight = useBottomTabBarHeight();
|
||
const insets = useSafeAreaInsets();
|
||
const bottomPadding = useMemo(() => {
|
||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||
}, [tabBarHeight, insets?.bottom]);
|
||
|
||
// 获å<C2B7>–当å‰<C3A5>选䏿—¥æœŸ - 使用 useMemo 缓å˜é<CB9C>¿å…<C3A5>é‡<C3A9>å¤<C3A5>计算
|
||
const currentSelectedDate = useMemo(() => {
|
||
const days = getMonthDaysZh();
|
||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||
}, [selectedIndex]);
|
||
|
||
const currentSelectedDateString = useMemo(() => {
|
||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||
}, [currentSelectedDate]);
|
||
|
||
// 从 Redux 获å<C2B7>–指定日期的å<E2809E>¥åº·æ•°æ<C2B0>®
|
||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||
|
||
|
||
// 解构å<E2809E>¥åº·æ•°æ<C2B0>®ï¼ˆæ”¯æŒ<C3A6>mockæ•°æ<C2B0>®ï¼‰
|
||
const mockData = useMockData ? getTestHealthData('mock') : null;
|
||
const stepCount: number | null = useMockData ? (mockData?.steps ?? null) : (healthData?.steps ?? null);
|
||
const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []);
|
||
const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
|
||
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
|
||
const hrvValue = useMockData ? (mockData?.hrv ?? null) : (healthData?.hrv ?? null);
|
||
const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
|
||
|
||
// 调试HRVæ•°æ<C2B0>®
|
||
console.log('=== HRVæ•°æ<C2B0>®è°ƒè¯• ===');
|
||
console.log('useMockData:', useMockData);
|
||
console.log('mockData?.hrv:', mockData?.hrv);
|
||
console.log('healthData?.hrv:', healthData?.hrv);
|
||
console.log('final hrvValue:', hrvValue);
|
||
console.log('healthData:', healthData);
|
||
console.log('==================');
|
||
|
||
const fitnessRingsData = useMockData ? {
|
||
activeCalories: mockData?.activeCalories ?? 0,
|
||
activeCaloriesGoal: mockData?.activeCaloriesGoal ?? 350,
|
||
exerciseMinutes: mockData?.exerciseMinutes ?? 0,
|
||
exerciseMinutesGoal: mockData?.exerciseMinutesGoal ?? 30,
|
||
standHours: mockData?.standHours ?? 0,
|
||
standHoursGoal: mockData?.standHoursGoal ?? 12,
|
||
} : (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,
|
||
});
|
||
|
||
// HRVæ›´æ–°æ—¶é—´
|
||
const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date());
|
||
|
||
// 用于触å<C2A6>‘动画é‡<C3A9>置的 token(当日期或数æ<C2B0>®å<C2AE>˜åŒ–时更新)
|
||
const [animToken, setAnimToken] = useState(0);
|
||
|
||
// 从 Redux 获å<C2B7>–è<E28093>¥å…»æ•°æ<C2B0>®
|
||
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
|
||
|
||
// 计算用户的è<E2809E>¥å…»ç›®æ ‡
|
||
const nutritionGoals = useMemo(() => {
|
||
return calculateNutritionGoals({
|
||
weight: userProfile.weight,
|
||
height: userProfile.height,
|
||
birthDate: userProfile?.birthDate ? new Date(userProfile?.birthDate) : undefined,
|
||
gender: userProfile?.gender || undefined,
|
||
});
|
||
}, [userProfile]);
|
||
|
||
// 心情相关状æ€<C3A6>
|
||
const dispatch = useAppDispatch();
|
||
const [isMoodLoading, setIsMoodLoading] = useState(false);
|
||
|
||
// 记录最近一次请求的"日期键",é<C592>¿å…<C3A5>旧请求覆盖新结果
|
||
const latestRequestKeyRef = useRef<string | null>(null);
|
||
|
||
// 请求状æ€<C3A6>管ç<C2A1>†ï¼Œé˜²æ¢é‡<C3A9>å¤<C3A5>请求
|
||
const loadingRef = useRef({
|
||
health: false,
|
||
nutrition: false,
|
||
mood: false
|
||
});
|
||
|
||
// æ•°æ<C2B0>®ç¼“å˜æ—¶é—´æˆ³ï¼Œé<C592>¿å…<C3A5>çŸæ—¶é—´å†…é‡<C3A9>å¤<C3A5>拉å<E280B0>–
|
||
const dataTimestampRef = useRef<{ [key: string]: number }>({});
|
||
|
||
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
||
|
||
// 检查数æ<C2B0>®æ˜¯å<C2AF>¦éœ€è¦<C3A8>刷新(2分钟内ä¸<C3A4>é‡<C3A9>å¤<C3A5>拉å<E280B0>–,对è<C2B9>¥å…»æ•°æ<C2B0>®æ›´ä¸¥æ ¼ï¼‰
|
||
const shouldRefreshData = (dateKey: string, dataType: string) => {
|
||
const cacheKey = `${dateKey}-${dataType}`;
|
||
const lastUpdate = dataTimestampRef.current[cacheKey];
|
||
const now = Date.now();
|
||
|
||
// è<>¥å…»æ•°æ<C2B0>®ä½¿ç”¨æ›´çŸçš„ç¼“å˜æ—¶é—´ï¼Œå…¶ä»–æ•°æ<C2B0>®ä½¿ç”¨5分钟
|
||
const cacheTime = dataType === 'nutrition' ? 2 * 60 * 1000 : 5 * 60 * 1000;
|
||
|
||
return !lastUpdate || (now - lastUpdate) > cacheTime;
|
||
};
|
||
|
||
// æ›´æ–°æ•°æ<C2B0>®æ—¶é—´æˆ³
|
||
const updateDataTimestamp = (dateKey: string, dataType: string) => {
|
||
const cacheKey = `${dateKey}-${dataType}`;
|
||
dataTimestampRef.current[cacheKey] = Date.now();
|
||
};
|
||
|
||
|
||
// 从 Redux 获å<C2B7>–当å‰<C3A5>日期的心情记录
|
||
const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate(
|
||
currentSelectedDateString
|
||
));
|
||
|
||
// åŠ è½½å¿ƒæƒ…æ•°æ<C2B0>®
|
||
const loadMoodData = async (targetDate?: Date, forceRefresh = false) => {
|
||
if (!isLoggedIn) return;
|
||
|
||
// 确定è¦<C3A8>查询的日期
|
||
let derivedDate: Date;
|
||
if (targetDate) {
|
||
derivedDate = targetDate;
|
||
} else {
|
||
derivedDate = currentSelectedDate;
|
||
}
|
||
|
||
const requestKey = getDateKey(derivedDate);
|
||
|
||
// 检查是å<C2AF>¦æ£åœ¨åŠ è½½æˆ–ä¸<C3A4>需è¦<C3A8>刷新
|
||
if (loadingRef.current.mood) {
|
||
console.log('心情数æ<C2B0>®æ£åœ¨åŠ è½½ä¸ï¼Œè·³è¿‡é‡<C3A9>å¤<C3A5>请求');
|
||
return;
|
||
}
|
||
|
||
if (!forceRefresh && !shouldRefreshData(requestKey, 'mood')) {
|
||
console.log('心情数æ<C2B0>®ç¼“å˜æœªè¿‡æœŸï¼Œè·³è¿‡è¯·æ±‚');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
loadingRef.current.mood = true;
|
||
setIsMoodLoading(true);
|
||
|
||
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||
await dispatch(fetchDailyMoodCheckins(dateString));
|
||
|
||
// æ›´æ–°ç¼“å˜æ—¶é—´æˆ³
|
||
updateDataTimestamp(requestKey, 'mood');
|
||
|
||
} catch (error) {
|
||
console.error('åŠ è½½å¿ƒæƒ…æ•°æ<C2B0>®å¤±è´¥:', error);
|
||
} finally {
|
||
loadingRef.current.mood = false;
|
||
setIsMoodLoading(false);
|
||
}
|
||
};
|
||
|
||
|
||
|
||
const loadHealthData = async (targetDate?: Date, forceRefresh = false) => {
|
||
// 确定è¦<C3A8>查询的日期
|
||
let derivedDate: Date;
|
||
if (targetDate) {
|
||
derivedDate = targetDate;
|
||
} else {
|
||
derivedDate = currentSelectedDate;
|
||
}
|
||
|
||
const requestKey = getDateKey(derivedDate);
|
||
|
||
// 检查是å<C2AF>¦æ£åœ¨åŠ è½½æˆ–ä¸<C3A4>需è¦<C3A8>刷新
|
||
if (loadingRef.current.health) {
|
||
console.log('å<>¥åº·æ•°æ<C2B0>®æ£åœ¨åŠ è½½ä¸ï¼Œè·³è¿‡é‡<C3A9>å¤<C3A5>请求');
|
||
return;
|
||
}
|
||
|
||
if (!forceRefresh && !shouldRefreshData(requestKey, 'health')) {
|
||
console.log('å<>¥åº·æ•°æ<C2B0>®ç¼“å˜æœªè¿‡æœŸï¼Œè·³è¿‡è¯·æ±‚');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
loadingRef.current.health = true;
|
||
console.log('=== 开始HealthKitåˆ<C3A5>始化æµ<C3A6>程 ===');
|
||
|
||
const ok = await ensureHealthPermissions();
|
||
if (!ok) {
|
||
const errorMsg = 'æ— æ³•èŽ·å<C2B7>–å<E28093>¥åº·æ<C2B7>ƒé™<C3A9>,请确ä¿<C3A4>在真实iOS设备上è¿<C3A8>行并授æ<CB86>ƒåº”用访问å<C2AE>¥åº·æ•°æ<C2B0>®';
|
||
console.warn(errorMsg);
|
||
return;
|
||
}
|
||
|
||
latestRequestKeyRef.current = requestKey;
|
||
|
||
console.log('æ<>ƒé™<C3A9>获å<C2B7>–æˆ<C3A6>功,开始获å<C2B7>–å<E28093>¥åº·æ•°æ<C2B0>®...', derivedDate);
|
||
const data = await fetchHealthDataForDate(derivedDate);
|
||
|
||
console.log('设置UI状æ€<C3A6>:', data);
|
||
console.log('HRVæ•°æ<C2B0>®è¯¦ç»†ä¿¡æ<C2A1>¯:', data.hrv, typeof data.hrv);
|
||
|
||
// 仅当该请求ä»<C3A4>是最新时,æ‰<C3A6>应用结果
|
||
if (latestRequestKeyRef.current === requestKey) {
|
||
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||
|
||
// 使用 Redux å˜å‚¨å<C2A8>¥åº·æ•°æ<C2B0>®
|
||
dispatch(setHealthData({
|
||
date: dateString,
|
||
data: {
|
||
steps: data.steps,
|
||
activeCalories: data.activeEnergyBurned,
|
||
basalEnergyBurned: data.basalEnergyBurned,
|
||
hrv: data.hrv,
|
||
oxygenSaturation: data.oxygenSaturation,
|
||
heartRate: data.heartRate,
|
||
activeEnergyBurned: data.activeEnergyBurned,
|
||
activeCaloriesGoal: data.activeCaloriesGoal,
|
||
exerciseMinutes: data.exerciseMinutes,
|
||
exerciseMinutesGoal: data.exerciseMinutesGoal,
|
||
standHours: data.standHours,
|
||
standHoursGoal: data.standHoursGoal,
|
||
hourlySteps: data.hourlySteps,
|
||
}
|
||
}));
|
||
|
||
// æ›´æ–°HRVæ•°æ<C2B0>®æ—¶é—´
|
||
setHrvUpdateTime(new Date());
|
||
setAnimToken((t) => t + 1);
|
||
|
||
// æ›´æ–°ç¼“å˜æ—¶é—´æˆ³
|
||
updateDataTimestamp(requestKey, 'health');
|
||
} else {
|
||
console.log('忽略过期å<C5B8>¥åº·æ•°æ<C2B0>®è¯·æ±‚结果,key=', requestKey, '最新key=', latestRequestKeyRef.current);
|
||
}
|
||
console.log('=== HealthKitæ•°æ<C2B0>®èŽ·å<C2B7>–完æˆ<C3A6> ===');
|
||
|
||
} catch (error) {
|
||
console.error('HealthKitæµ<C3A6>程出现异常:', error);
|
||
} finally {
|
||
loadingRef.current.health = false;
|
||
}
|
||
};
|
||
|
||
// åŠ è½½è<C2BD>¥å…»æ•°æ<C2B0>®
|
||
const loadNutritionData = async (targetDate?: Date, forceRefresh = false) => {
|
||
if (!isLoggedIn) return;
|
||
|
||
// 确定è¦<C3A8>查询的日期
|
||
let derivedDate: Date;
|
||
if (targetDate) {
|
||
derivedDate = targetDate;
|
||
} else {
|
||
derivedDate = currentSelectedDate;
|
||
}
|
||
|
||
const requestKey = getDateKey(derivedDate);
|
||
|
||
// 检查是å<C2AF>¦æ£åœ¨åŠ è½½æˆ–ä¸<C3A4>需è¦<C3A8>刷新
|
||
if (loadingRef.current.nutrition) {
|
||
console.log('è<>¥å…»æ•°æ<C2B0>®æ£åœ¨åŠ è½½ä¸ï¼Œè·³è¿‡é‡<C3A9>å¤<C3A5>请求');
|
||
return;
|
||
}
|
||
|
||
if (!forceRefresh && !shouldRefreshData(requestKey, 'nutrition')) {
|
||
console.log('è<>¥å…»æ•°æ<C2B0>®ç¼“å˜æœªè¿‡æœŸï¼Œè·³è¿‡è¯·æ±‚');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
loadingRef.current.nutrition = true;
|
||
console.log('åŠ è½½è<C2BD>¥å…»æ•°æ<C2B0>®...', derivedDate);
|
||
await dispatch(fetchDailyNutritionData(derivedDate));
|
||
console.log('è<>¥å…»æ•°æ<C2B0>®åŠ è½½å®Œæˆ<C3A6>');
|
||
|
||
// æ›´æ–°ç¼“å˜æ—¶é—´æˆ³
|
||
updateDataTimestamp(requestKey, 'nutrition');
|
||
|
||
} catch (error) {
|
||
console.error('è<>¥å…»æ•°æ<C2B0>®åŠ è½½å¤±è´¥:', error);
|
||
} finally {
|
||
loadingRef.current.nutrition = false;
|
||
}
|
||
};
|
||
|
||
// 实际执行数æ<C2B0>®åŠ è½½çš„æ–¹æ³•
|
||
const executeLoadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
|
||
const dateToUse = targetDate || currentSelectedDate;
|
||
if (dateToUse) {
|
||
console.log('执行数æ<C2B0>®åŠ è½½ï¼Œæ—¥æœŸ:', dateToUse, '强制刷新:', forceRefresh);
|
||
loadHealthData(dateToUse, forceRefresh);
|
||
if (isLoggedIn) {
|
||
loadNutritionData(dateToUse, forceRefresh);
|
||
loadMoodData(dateToUse, forceRefresh);
|
||
// åŠ è½½å–<C3A5>æ°´æ•°æ<C2B0>®ï¼ˆå<CB86>ªåŠ è½½ä»Šæ—¥æ•°æ<C2B0>®ç”¨äºŽå<C5BD>Žå<C5BD>°æ£€æŸ¥ï¼‰
|
||
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
|
||
if (isToday) {
|
||
dispatch(fetchTodayWaterStats());
|
||
}
|
||
}
|
||
}
|
||
}, [isLoggedIn, dispatch]);
|
||
|
||
// 使用 lodash debounce é˜²æŠ–çš„åŠ è½½æ‰€æœ‰æ•°æ<C2B0>®æ–¹æ³•
|
||
const debouncedLoadAllData = React.useMemo(
|
||
() => debounce(executeLoadAllData, 500), // 500ms 防抖延迟
|
||
[executeLoadAllData]
|
||
);
|
||
|
||
// 对外暴露的 loadAllData 方法
|
||
const loadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
|
||
if (forceRefresh) {
|
||
// 如果是强制刷新,立å<E280B9>³æ‰§è¡Œï¼Œä¸<C3A4>使用防抖
|
||
executeLoadAllData(targetDate, forceRefresh);
|
||
} else {
|
||
// 普通调用使用防抖
|
||
debouncedLoadAllData(targetDate, forceRefresh);
|
||
}
|
||
}, [executeLoadAllData, debouncedLoadAllData]);
|
||
|
||
// 页é<C2B5>¢è<C2A2>šç„¦æ—¶çš„æ•°æ<C2B0>®åŠ è½½é€»è¾‘
|
||
// useFocusEffect(
|
||
// React.useCallback(() => {
|
||
// // 页é<C2B5>¢è<C2A2>šç„¦æ—¶åŠ è½½æ•°æ<C2B0>®ï¼Œä½¿ç”¨ç¼“å˜æœºåˆ¶é<C2B6>¿å…<C3A5>频ç¹<C3A7>请求
|
||
// console.log('页é<C2B5>¢è<C2A2>šç„¦ï¼Œæ£€æŸ¥æ˜¯å<C2AF>¦éœ€è¦<C3A8>刷新数æ<C2B0>®...');
|
||
// loadAllData(currentSelectedDate);
|
||
// }, [loadAllData, currentSelectedDate])
|
||
// );
|
||
|
||
// AppState 监å<E28098>¬ï¼šåº”用从å<C5BD>Žå<C5BD>°è¿”回å‰<C3A5>å<EFBFBD>°æ—¶çš„处ç<E2809E>†
|
||
useEffect(() => {
|
||
const handleAppStateChange = (nextAppState: string) => {
|
||
if (nextAppState === 'active') {
|
||
// 判æ–当å‰<C3A5>选ä¸çš„æ—¥æœŸæ˜¯å<C2AF>¦æ˜¯æœ€æ–°çš„(今天)
|
||
const todayIndex = getTodayIndexInMonth();
|
||
const isTodaySelected = selectedIndex === todayIndex;
|
||
|
||
if (!isTodaySelected) {
|
||
// 如果当å‰<C3A5>ä¸<C3A4>是选ä¸ä»Šå¤©ï¼Œåˆ™åˆ‡æ<E280A1>¢åˆ°ä»Šå¤©ï¼ˆè¿™ä¸ªæ›´æ–°ä¼šè§¦å<C2A6>‘æ•°æ<C2B0>®åŠ è½½ï¼‰
|
||
console.log('应用回到å‰<C3A5>å<EFBFBD>°ï¼Œåˆ‡æ<E280A1>¢åˆ°ä»Šå¤©å¹¶åŠ è½½æ•°æ<C2B0>®');
|
||
setSelectedIndex(todayIndex);
|
||
// 注æ„<C3A6>:这里ä¸<C3A4>直接调用loadAllDataï¼Œå› ä¸ºsetSelectedIndex会触å<C2A6>‘useEffecté‡<C3A9>新计算currentSelectedDate
|
||
// ç„¶å<C2B6>ŽonSelectDate会被调用,从而触å<C2A6>‘æ•°æ<C2B0>®åŠ è½½
|
||
} else {
|
||
// 如果已ç»<C3A7>æ˜¯ä»Šå¤©ï¼Œåˆ™ç›´æŽ¥è°ƒç”¨åŠ è½½æ•°æ<C2B0>®çš„æ–¹æ³•
|
||
console.log('应用回到å‰<C3A5>å<EFBFBD>°ï¼Œå½“å‰<C3A5>å·²æ˜¯ä»Šå¤©ï¼Œç›´æŽ¥åŠ è½½æ•°æ<C2B0>®');
|
||
loadAllData(currentSelectedDate);
|
||
}
|
||
}
|
||
};
|
||
|
||
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||
|
||
return () => {
|
||
subscription?.remove();
|
||
};
|
||
}, [loadAllData, currentSelectedDate, selectedIndex]);
|
||
|
||
|
||
// æ—¥æœŸç‚¹å‡»æ—¶ï¼ŒåŠ è½½å¯¹åº”æ—¥æœŸæ•°æ<C2B0>®
|
||
const onSelectDate = React.useCallback((index: number, date: Date) => {
|
||
setSelectedIndex(index);
|
||
console.log('日期切æ<E280A1>¢ï¼ŒåŠ è½½æ•°æ<C2B0>®...', date);
|
||
// 日期切æ<E280A1>¢æ—¶ä¸<C3A4>强制刷新,ä¾<C3A4>èµ–ç¼“å˜æœºåˆ¶å‡<C3A5>å°‘ä¸<C3A4>å¿…è¦<C3A8>的请求
|
||
// loadAllData 内部已ç»<C3A7>å®žçŽ°äº†é˜²æŠ–ï¼Œæ— éœ€é¢<C3A9>外防抖处ç<E2809E>†
|
||
loadAllData(date, false);
|
||
}, [loadAllData]);
|
||
|
||
|
||
return (
|
||
<View style={styles.container}>
|
||
{/* 背景æ¸<C3A6>å<EFBFBD>˜ */}
|
||
<LinearGradient
|
||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||
style={styles.gradientBackground}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 0, y: 1 }}
|
||
/>
|
||
|
||
{/* 装饰性圆圈 */}
|
||
<View style={styles.decorativeCircle1} />
|
||
<View style={styles.decorativeCircle2} />
|
||
|
||
<ScrollView
|
||
style={styles.scrollView}
|
||
contentContainerStyle={{
|
||
paddingTop: insets.top,
|
||
paddingBottom: bottomPadding,
|
||
paddingHorizontal: 20
|
||
}}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
{/* 顶部信æ<C2A1>¯æ <C3A6> */}
|
||
<View style={styles.headerContainer}>
|
||
<View style={styles.headerContent}>
|
||
{/* 左边logo */}
|
||
<Image
|
||
source={require('@/assets/images/Sealife.jpeg')}
|
||
style={styles.logoImage}
|
||
resizeMode="cover"
|
||
/>
|
||
|
||
{/* å<>³è¾¹æ–‡å—区域 */}
|
||
<View style={styles.headerTextContainer}>
|
||
<Text style={styles.headerTitle}>Out Live</Text>
|
||
</View>
|
||
|
||
{/* å¼€å<E282AC>‘环境调试按钮 */}
|
||
{__DEV__ && (
|
||
<View style={styles.debugButtonsContainer}>
|
||
<TouchableOpacity
|
||
style={styles.debugButton}
|
||
onPress={async () => {
|
||
console.log('🔧 手动触å<C2A6>‘å<E28098>Žå<C5BD>°ä»»åŠ¡æµ‹è¯•...');
|
||
await backgroundTaskManager.triggerTaskForTesting();
|
||
}}
|
||
>
|
||
<Text style={styles.debugButtonText}>🔧</Text>
|
||
</TouchableOpacity>
|
||
|
||
<TouchableOpacity
|
||
style={[styles.debugButton, styles.hrvTestButton]}
|
||
onPress={async () => {
|
||
console.log('🫀 测试HRVæ•°æ<C2B0>®èŽ·å<C2B7>–...');
|
||
await testHRVDataFetch();
|
||
}}
|
||
>
|
||
<Text style={styles.debugButtonText}>🫀</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
|
||
|
||
{/* 日期选择器 */}
|
||
<DateSelector
|
||
selectedIndex={selectedIndex}
|
||
onDateSelect={onSelectDate}
|
||
showMonthTitle={false}
|
||
disableFutureDates={true}
|
||
/>
|
||
|
||
|
||
|
||
{/* è<>¥å…»æ‘„入雷达图å<C2BE>¡ç‰‡ */}
|
||
<NutritionRadarCard
|
||
nutritionSummary={nutritionSummary}
|
||
nutritionGoals={nutritionGoals}
|
||
burnedCalories={(basalMetabolism || 0) + (activeCalories || 0)}
|
||
basalMetabolism={basalMetabolism || 0}
|
||
activeCalories={activeCalories || 0}
|
||
resetToken={animToken}
|
||
onMealPress={(mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => {
|
||
console.log('选择é¤<C3A9>次:', mealType);
|
||
// 这里å<C592>¯ä»¥å¯¼èˆªåˆ°è<C2B0>¥å…»è®°å½•页é<C2B5>¢
|
||
pushIfAuthedElseLogin('/nutrition/records');
|
||
}}
|
||
/>
|
||
|
||
<WeightHistoryCard />
|
||
|
||
{/* 真æ£ç€‘布æµ<C3A6>布局 */}
|
||
<View style={styles.masonryContainer}>
|
||
{/* 左列 */}
|
||
<View style={styles.masonryColumn}>
|
||
{/* 心情å<E280A6>¡ç‰‡ */}
|
||
<FloatingCard style={styles.masonryCard} delay={1500}>
|
||
<MoodCard
|
||
moodCheckin={currentMoodCheckin}
|
||
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
|
||
isLoading={isMoodLoading}
|
||
/>
|
||
</FloatingCard>
|
||
|
||
<FloatingCard style={styles.masonryCard}>
|
||
<StepsCard
|
||
stepCount={stepCount}
|
||
stepGoal={stepGoal}
|
||
hourlySteps={hourlySteps}
|
||
style={styles.stepsCardOverride}
|
||
onPress={() => pushIfAuthedElseLogin('/steps/detail')}
|
||
/>
|
||
</FloatingCard>
|
||
|
||
|
||
|
||
<FloatingCard style={styles.masonryCard} delay={0}>
|
||
<StressMeter
|
||
value={hrvValue}
|
||
updateTime={hrvUpdateTime}
|
||
hrvValue={hrvValue}
|
||
/>
|
||
</FloatingCard>
|
||
|
||
{/* 心率å<E280A1>¡ç‰‡ */}
|
||
{/* <FloatingCard style={styles.masonryCard} delay={2000}>
|
||
<HeartRateCard
|
||
resetToken={animToken}
|
||
style={styles.basalMetabolismCardOverride}
|
||
heartRate={heartRate}
|
||
/>
|
||
</FloatingCard> */}
|
||
|
||
<FloatingCard style={styles.masonryCard}>
|
||
<SleepCard
|
||
selectedDate={currentSelectedDate}
|
||
onPress={() => pushIfAuthedElseLogin(`/sleep-detail?date=${dayjs(currentSelectedDate).format('YYYY-MM-DD')}`)}
|
||
/>
|
||
</FloatingCard>
|
||
</View>
|
||
|
||
{/* å<>³åˆ— */}
|
||
<View style={styles.masonryColumn}>
|
||
<FloatingCard style={styles.masonryCard} delay={250}>
|
||
<FitnessRingsCard
|
||
activeCalories={fitnessRingsData.activeCalories}
|
||
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
|
||
exerciseMinutes={fitnessRingsData.exerciseMinutes}
|
||
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
|
||
standHours={fitnessRingsData.standHours}
|
||
standHoursGoal={fitnessRingsData.standHoursGoal}
|
||
resetToken={animToken}
|
||
/>
|
||
</FloatingCard>
|
||
{/* 饮水记录å<E280A2>¡ç‰‡ */}
|
||
<FloatingCard style={styles.masonryCard} delay={500}>
|
||
<WaterIntakeCard
|
||
selectedDate={currentSelectedDateString}
|
||
style={styles.waterCardOverride}
|
||
/>
|
||
</FloatingCard>
|
||
|
||
|
||
{/* 基础代谢å<C2A2>¡ç‰‡ */}
|
||
<FloatingCard style={styles.masonryCard} delay={1250}>
|
||
<BasalMetabolismCard
|
||
value={basalMetabolism}
|
||
resetToken={animToken}
|
||
style={styles.basalMetabolismCardOverride}
|
||
/>
|
||
</FloatingCard>
|
||
|
||
{/* 血氧饱和度å<C2A6>¡ç‰‡ */}
|
||
<FloatingCard style={styles.masonryCard} delay={1750}>
|
||
<OxygenSaturationCard
|
||
resetToken={animToken}
|
||
style={styles.basalMetabolismCardOverride}
|
||
oxygenSaturation={oxygenSaturation}
|
||
/>
|
||
</FloatingCard>
|
||
|
||
|
||
</View>
|
||
</View>
|
||
</ScrollView>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const primary = Colors.light.primary;
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
},
|
||
gradientBackground: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
},
|
||
decorativeCircle1: {
|
||
position: 'absolute',
|
||
top: 40,
|
||
right: 20,
|
||
width: 60,
|
||
height: 60,
|
||
borderRadius: 30,
|
||
backgroundColor: '#0EA5E9',
|
||
opacity: 0.1,
|
||
},
|
||
decorativeCircle2: {
|
||
position: 'absolute',
|
||
bottom: -15,
|
||
left: -15,
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
backgroundColor: '#0EA5E9',
|
||
opacity: 0.05,
|
||
},
|
||
|
||
scrollView: {
|
||
flex: 1,
|
||
},
|
||
headerContainer: {
|
||
marginBottom: 10,
|
||
},
|
||
headerContent: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
},
|
||
logoImage: {
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 20,
|
||
},
|
||
headerTextContainer: {
|
||
flex: 1,
|
||
marginLeft: 12,
|
||
},
|
||
headerTitle: {
|
||
fontSize: 16,
|
||
fontWeight: '500',
|
||
color: '#192126',
|
||
},
|
||
debugButtonsContainer: {
|
||
flexDirection: 'row',
|
||
gap: 8,
|
||
},
|
||
debugButton: {
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 16,
|
||
backgroundColor: '#FF6B6B',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
shadowColor: '#000',
|
||
shadowOffset: {
|
||
width: 0,
|
||
height: 2,
|
||
},
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 4,
|
||
elevation: 3,
|
||
},
|
||
hrvTestButton: {
|
||
backgroundColor: '#8B5CF6',
|
||
},
|
||
debugButtonText: {
|
||
fontSize: 12,
|
||
},
|
||
|
||
|
||
sectionTitle: {
|
||
fontSize: 24,
|
||
fontWeight: '800',
|
||
color: '#192126',
|
||
marginTop: 24,
|
||
marginBottom: 14,
|
||
},
|
||
metricsRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 16,
|
||
},
|
||
card: {
|
||
backgroundColor: '#0F1418',
|
||
borderRadius: 22,
|
||
padding: 18,
|
||
marginBottom: 16,
|
||
},
|
||
metricsLeft: {
|
||
flex: 1,
|
||
backgroundColor: '#EEE9FF',
|
||
borderRadius: 22,
|
||
padding: 18,
|
||
marginRight: 12,
|
||
},
|
||
metricsRight: {
|
||
width: 160,
|
||
gap: 12,
|
||
},
|
||
metricsRightCard: {
|
||
backgroundColor: '#FFFFFF',
|
||
borderRadius: 22,
|
||
padding: 16,
|
||
},
|
||
caloriesValue: {
|
||
color: '#192126',
|
||
fontSize: 18,
|
||
lineHeight: 18,
|
||
fontWeight: '600',
|
||
textAlignVertical: 'bottom'
|
||
},
|
||
caloriesUnit: {
|
||
color: '#515558ff',
|
||
fontSize: 12,
|
||
marginLeft: 4,
|
||
lineHeight: 18,
|
||
},
|
||
trainingContent: {
|
||
marginTop: 8,
|
||
width: 120,
|
||
height: 120,
|
||
borderRadius: 60,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
alignSelf: 'center',
|
||
},
|
||
trainingRingTrack: {
|
||
position: 'absolute',
|
||
width: '100%',
|
||
height: '100%',
|
||
borderRadius: 60,
|
||
borderWidth: 12,
|
||
borderColor: '#E2D9FD',
|
||
},
|
||
trainingRingProgress: {
|
||
position: 'absolute',
|
||
width: '100%',
|
||
height: '100%',
|
||
borderRadius: 60,
|
||
borderWidth: 12,
|
||
borderColor: 'transparent',
|
||
borderTopColor: '#8B74F3',
|
||
borderRightColor: '#8B74F3',
|
||
transform: [{ rotateZ: '45deg' }],
|
||
},
|
||
trainingPercent: {
|
||
fontSize: 18,
|
||
fontWeight: '800',
|
||
color: '#8B74F3',
|
||
},
|
||
cyclingHeader: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginBottom: 12,
|
||
},
|
||
cyclingIconBadge: {
|
||
width: 30,
|
||
height: 30,
|
||
borderRadius: 6,
|
||
backgroundColor: primary,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 8,
|
||
},
|
||
cyclingTitle: {
|
||
color: '#FFFFFF',
|
||
fontSize: 20,
|
||
fontWeight: '800',
|
||
},
|
||
mapArea: {
|
||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||
borderRadius: 14,
|
||
height: 180,
|
||
padding: 8,
|
||
flexDirection: 'row',
|
||
flexWrap: 'wrap',
|
||
overflow: 'hidden',
|
||
},
|
||
mapTile: {
|
||
width: '25%',
|
||
height: '25%',
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(255,255,255,0.12)',
|
||
},
|
||
routeLine: {
|
||
position: 'absolute',
|
||
height: 6,
|
||
backgroundColor: primary,
|
||
borderRadius: 3,
|
||
},
|
||
cardHeaderRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginBottom: 12,
|
||
},
|
||
iconSquare: {
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: 8,
|
||
backgroundColor: '#FFFFFF',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 10,
|
||
},
|
||
cardTitle: {
|
||
fontSize: 14,
|
||
color: '#192126',
|
||
},
|
||
heartCard: {
|
||
backgroundColor: '#FFE5E5',
|
||
},
|
||
waveContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'flex-end',
|
||
height: 70,
|
||
gap: 6,
|
||
marginBottom: 8,
|
||
},
|
||
waveBar: {
|
||
width: 6,
|
||
borderRadius: 3,
|
||
backgroundColor: '#E54D4D',
|
||
},
|
||
heartValue: {
|
||
alignSelf: 'flex-end',
|
||
color: '#5B5B5B',
|
||
fontWeight: '600',
|
||
},
|
||
stepsValue: {
|
||
fontSize: 14,
|
||
color: '#7A6A42',
|
||
fontWeight: '700',
|
||
marginBottom: 8,
|
||
},
|
||
errorContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
backgroundColor: '#FFE5E5',
|
||
borderRadius: 12,
|
||
padding: 12,
|
||
marginBottom: 16,
|
||
},
|
||
errorText: {
|
||
fontSize: 14,
|
||
color: '#E54D4D',
|
||
fontWeight: '600',
|
||
marginLeft: 8,
|
||
flex: 1,
|
||
},
|
||
retryButton: {
|
||
padding: 4,
|
||
marginLeft: 8,
|
||
},
|
||
viewMoreContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginBottom: 16,
|
||
},
|
||
viewMoreText: {
|
||
fontSize: 14,
|
||
color: '#192126',
|
||
},
|
||
viewMoreIcon: {
|
||
fontSize: 16,
|
||
color: '#192126',
|
||
marginLeft: 4,
|
||
},
|
||
stressCardRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'flex-start',
|
||
marginBottom: 16,
|
||
},
|
||
healthCardsRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 16,
|
||
},
|
||
masonryContainer: {
|
||
marginBottom: 16,
|
||
flexDirection: 'row',
|
||
gap: 16,
|
||
marginTop: 6,
|
||
},
|
||
masonryColumn: {
|
||
flex: 1,
|
||
},
|
||
masonryCard: {
|
||
width: '100%',
|
||
backgroundColor: '#FFFFFF',
|
||
borderRadius: 16,
|
||
padding: 16,
|
||
shadowColor: '#000',
|
||
shadowOffset: {
|
||
width: 0,
|
||
height: 4,
|
||
},
|
||
shadowOpacity: 0.12,
|
||
shadowRadius: 12,
|
||
elevation: 6,
|
||
minHeight: 100,
|
||
justifyContent: 'center',
|
||
marginTop: 6
|
||
},
|
||
basalMetabolismCardOverride: {
|
||
margin: -16, // 抵消 masonryCard 的 padding
|
||
borderRadius: 16,
|
||
},
|
||
stepsCardOverride: {
|
||
margin: -16, // 抵消 masonryCard 的 padding
|
||
borderRadius: 16,
|
||
height: '100%', // 填充整个masonryCard
|
||
},
|
||
waterCardOverride: {
|
||
margin: -16, // 抵消 masonryCard 的 padding
|
||
borderRadius: 16,
|
||
height: '100%', // 填充整个masonryCard
|
||
},
|
||
compactStepsCard: {
|
||
minHeight: 100,
|
||
},
|
||
stepsContent: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
marginTop: 8,
|
||
},
|
||
weightCard: {
|
||
backgroundColor: '#F0F9FF',
|
||
},
|
||
weightValue: {
|
||
fontSize: 22,
|
||
color: '#0369A1',
|
||
fontWeight: '800',
|
||
marginTop: 8,
|
||
},
|
||
addWeightButton: {
|
||
position: 'absolute',
|
||
right: 0,
|
||
top: 0,
|
||
padding: 4,
|
||
},
|
||
|
||
|
||
});
|