import { AnimatedNumber } from '@/components/AnimatedNumber'; import { BMICard } from '@/components/BMICard'; import { FitnessRingsCard } from '@/components/FitnessRingsCard'; import { MoodCard } from '@/components/MoodCard'; import { NutritionRadarCard } from '@/components/NutritionRadarCard'; import { ProgressBar } from '@/components/ProgressBar'; import { StressMeter } from '@/components/StressMeter'; import { WeightHistoryCard } from '@/components/WeightHistoryCard'; import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; import { calculateNutritionSummary, getDietRecords, NutritionSummary } from '@/services/dietRecords'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; import dayjs from 'dayjs'; import { LinearGradient } from 'expo-linear-gradient'; import { router } from 'expo-router'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Animated, SafeAreaView, 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; }) => { const floatAnim = useRef(new Animated.Value(0)).current; useEffect(() => { const startAnimation = () => { Animated.loop( Animated.sequence([ Animated.timing(floatAnim, { toValue: 1, duration: 3000, delay: delay, useNativeDriver: true, }), Animated.timing(floatAnim, { toValue: 0, duration: 3000, useNativeDriver: true, }), ]) ).start(); }; startAnimation(); }, [floatAnim, delay]); const translateY = floatAnim.interpolate({ inputRange: [0, 1], outputRange: [-2, -6], }); return ( {children} ); }; 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(); // 使用 dayjs:当月日期与默认选中"今天" const days = getMonthDaysZh(); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); const tabBarHeight = useBottomTabBarHeight(); const insets = useSafeAreaInsets(); const bottomPadding = useMemo(() => { return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0); }, [tabBarHeight, insets?.bottom]); const monthTitle = getMonthTitleZh(); // 日期条自动滚动到选中项 const daysScrollRef = useRef(null); const [scrollWidth, setScrollWidth] = useState(0); const DAY_PILL_WIDTH = 48; const DAY_PILL_SPACING = 8; const scrollToIndex = (index: number, animated = true) => { if (!daysScrollRef.current || scrollWidth === 0) return; const itemWidth = DAY_PILL_WIDTH + DAY_PILL_SPACING; const baseOffset = index * itemWidth; const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2)); // 确保不会滚动超出边界 const maxScrollOffset = Math.max(0, (days.length * itemWidth) - scrollWidth); const finalOffset = Math.min(centerOffset, maxScrollOffset); daysScrollRef.current.scrollTo({ x: finalOffset, animated }); }; useEffect(() => { if (scrollWidth > 0) { scrollToIndex(selectedIndex, false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [scrollWidth]); // 当选中索引变化时,滚动到对应位置 useEffect(() => { if (scrollWidth > 0) { scrollToIndex(selectedIndex, true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedIndex]); // HealthKit: 每次页面聚焦都拉取今日数据 const [stepCount, setStepCount] = useState(null); const [activeCalories, setActiveCalories] = useState(null); // 睡眠时长(分钟) const [sleepDuration, setSleepDuration] = useState(null); // HRV数据 const [hrvValue, setHrvValue] = useState(0); const [hrvUpdateTime, setHrvUpdateTime] = useState(new Date()); // 健身圆环数据 const [fitnessRingsData, setFitnessRingsData] = useState({ activeCalories: 0, activeCaloriesGoal: 350, exerciseMinutes: 0, exerciseMinutesGoal: 30, standHours: 0, standHoursGoal: 12 }); const [isLoading, setIsLoading] = useState(false); // 用于触发动画重置的 token(当日期或数据变化时更新) const [animToken, setAnimToken] = useState(0); const [trainingProgress, setTrainingProgress] = useState(0); // 暂定静态80% // 营养数据状态 const [nutritionSummary, setNutritionSummary] = useState(null); const [isNutritionLoading, setIsNutritionLoading] = useState(false); // 心情相关状态 const dispatch = useAppDispatch(); const [isMoodLoading, setIsMoodLoading] = useState(false); // 从 Redux 获取当前日期的心情记录 const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate( days[selectedIndex]?.date?.format('YYYY-MM-DD') || dayjs().format('YYYY-MM-DD') )); // 记录最近一次请求的"日期键",避免旧请求覆盖新结果 const latestRequestKeyRef = useRef(null); const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`; // 加载心情数据 const loadMoodData = async (targetDate?: Date) => { if (!isLoggedIn) return; try { setIsMoodLoading(true); // 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期 let derivedDate: Date; if (targetDate) { derivedDate = targetDate; } else { derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date(); } const dateString = dayjs(derivedDate).format('YYYY-MM-DD'); await dispatch(fetchDailyMoodCheckins(dateString)); } catch (error) { console.error('加载心情数据失败:', error); } finally { setIsMoodLoading(false); } }; const loadHealthData = async (targetDate?: Date) => { try { console.log('=== 开始HealthKit初始化流程 ==='); setIsLoading(true); const ok = await ensureHealthPermissions(); if (!ok) { const errorMsg = '无法获取健康权限,请确保在真实iOS设备上运行并授权应用访问健康数据'; console.warn(errorMsg); return; } // 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期 let derivedDate: Date; if (targetDate) { derivedDate = targetDate; } else { derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date(); } const requestKey = getDateKey(derivedDate); latestRequestKeyRef.current = requestKey; console.log('权限获取成功,开始获取健康数据...', derivedDate); const data = await fetchHealthDataForDate(derivedDate); console.log('设置UI状态:', data); // 仅当该请求仍是最新时,才应用结果 if (latestRequestKeyRef.current === requestKey) { setStepCount(data.steps); setActiveCalories(Math.round(data.activeEnergyBurned)); setSleepDuration(data.sleepDuration); // 更新健身圆环数据 setFitnessRingsData({ activeCalories: data.activeCalories, activeCaloriesGoal: data.activeCaloriesGoal, exerciseMinutes: data.exerciseMinutes, exerciseMinutesGoal: data.exerciseMinutesGoal, standHours: data.standHours, standHoursGoal: data.standHoursGoal }); const hrv = data.hrv ?? 0; setHrvValue(hrv); // 更新HRV数据时间 setHrvUpdateTime(new Date()); setAnimToken((t) => t + 1); } else { console.log('忽略过期健康数据请求结果,key=', requestKey, '最新key=', latestRequestKeyRef.current); } console.log('=== HealthKit数据获取完成 ==='); } catch (error) { console.error('HealthKit流程出现异常:', error); } finally { setIsLoading(false); } }; // 加载营养数据 const loadNutritionData = async (targetDate?: Date) => { try { setIsNutritionLoading(true); // 确定要查询的日期:优先使用传入的日期,否则使用当前选中索引对应的日期 let derivedDate: Date; if (targetDate) { derivedDate = targetDate; } else { derivedDate = days[selectedIndex]?.date?.toDate() ?? new Date(); } console.log('加载营养数据...', derivedDate); const data = await getDietRecords({ startDate: dayjs(derivedDate).startOf('day').toISOString(), endDate: dayjs(derivedDate).endOf('day').toISOString(), }); if (data.records.length > 0) { const summary = calculateNutritionSummary(data.records); setNutritionSummary(summary); } else { setNutritionSummary(null); } console.log('营养数据加载完成:', data); } catch (error) { console.error('营养数据加载失败:', error); setNutritionSummary(null); } finally { setIsNutritionLoading(false); } }; useFocusEffect( React.useCallback(() => { // 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致 const currentDate = days[selectedIndex]?.date?.toDate(); if (currentDate) { loadHealthData(currentDate); if (isLoggedIn) { loadNutritionData(currentDate); loadMoodData(currentDate); } } }, [selectedIndex]) ); // 日期点击时,加载对应日期数据 const onSelectDate = (index: number) => { setSelectedIndex(index); const target = days[index]?.date?.toDate(); if (target) { loadHealthData(target); if (isLoggedIn) { loadNutritionData(target); loadMoodData(target); } } }; // 使用统一的渐变背景色 const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const; return ( {/* 体重历史记录卡片 */} 健康数据 {/* 标题与日期选择 */} {monthTitle} setScrollWidth(e.nativeEvent.layout.width)} > {days.map((d, i) => { const selected = i === selectedIndex; const isFutureDate = d.date.isAfter(dayjs(), 'day'); return ( !isFutureDate && onSelectDate(i)} activeOpacity={isFutureDate ? 1 : 0.8} disabled={isFutureDate} > {d.weekdayZh} {d.dayOfMonth} {selected && } ); })} {/* 营养摄入雷达图卡片 */} {/* 真正瀑布流布局 */} {/* 左列 */} 消耗卡路里 {activeCalories != null ? ( `${Math.round(v)} 千卡`} /> ) : ( —— )} 步数 {stepCount != null ? ( `${Math.round(v)}/${stepGoal}`} /> ) : ( ——/{stepGoal} )} {/* 心情卡片 */} router.push('/mood/calendar')} isLoading={isMoodLoading} /> {/* 右列 */} 睡眠 {sleepDuration != null ? ( {Math.floor(sleepDuration / 60)}小时{Math.floor(sleepDuration % 60)}分钟 ) : ( —— )} ); } const primary = Colors.light.primary; const lightColors = Colors.light; const darkColors = Colors.dark; const styles = StyleSheet.create({ container: { flex: 1, }, gradientBackground: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, }, safeArea: { flex: 1, }, scrollView: { flex: 1, paddingHorizontal: 20, }, monthTitle: { fontSize: 24, fontWeight: '800', color: '#192126', marginTop: 8, marginBottom: 14, }, daysContainer: { paddingBottom: 8, }, dayItemWrapper: { alignItems: 'center', width: 48, marginRight: 8, }, dayPill: { width: 48, height: 48, borderRadius: 14, alignItems: 'center', justifyContent: 'center', }, dayPillNormal: { backgroundColor: lightColors.datePickerNormal, }, dayPillSelected: { backgroundColor: lightColors.datePickerSelected, }, dayPillDisabled: { backgroundColor: '#F5F5F5', opacity: 0.5, }, dayLabel: { fontSize: 12, fontWeight: '700', color: '#192126', marginBottom: 1, }, dayLabelSelected: { color: '#FFFFFF', }, dayLabelDisabled: { color: '#9AA3AE', }, dayDate: { fontSize: 12, fontWeight: '800', color: '#192126', }, dayDateSelected: { color: '#FFFFFF', }, dayDateDisabled: { color: '#9AA3AE', }, selectedDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: lightColors.datePickerSelected, marginTop: 6, marginBottom: 2, alignSelf: 'center', }, 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, }, caloriesCard: { backgroundColor: '#FFFFFF', }, trainingCard: { backgroundColor: '#EEE9FF', }, cardTitleSecondary: { color: '#9AA3AE', fontSize: 10, fontWeight: '600', marginBottom: 10, }, caloriesValue: { color: '#192126', fontSize: 18, fontWeight: '800', }, 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, fontWeight: '800', 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', }, stepsCard: { backgroundColor: '#FFE4B8', }, 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, }, compactBMICard: { width: 140, minHeight: 110, }, healthMetricsContainer: { marginBottom: 16, }, masonryContainer: { marginBottom: 16, flexDirection: 'row', gap: 12, }, 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, }, bmiCardOverride: { margin: -16, // 抵消 masonryCard 的 padding borderRadius: 16, }, compactStepsCard: { minHeight: 100, }, stepsContent: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 8, }, sleepCard: { backgroundColor: '#E8F4FD', }, sleepValue: { fontSize: 16, color: '#1E40AF', fontWeight: '700', marginTop: 8, }, weightCard: { backgroundColor: '#F0F9FF', }, weightValue: { fontSize: 22, color: '#0369A1', fontWeight: '800', marginTop: 8, }, addWeightButton: { position: 'absolute', right: 0, top: 0, padding: 4, }, moodCard: { backgroundColor: '#F0FDF4', }, });