Files
digital-pilates/app/(tabs)/statistics.tsx
richarjiang 098c65b23e feat: 更新心情相关页面和组件
- 在心情统计、日历和编辑页面中引入 HeaderBar 组件,提升界面一致性和用户体验
- 移除冗余的头部视图代码,简化组件结构
- 更新心情日历和编辑页面的样式,使用新的颜色常量,增强视觉效果
- 优化心情统计页面的加载状态显示,提升用户交互体验
2025-08-21 19:09:02 +08:00

867 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<Animated.View
style={[
style,
{
transform: [{ translateY }],
marginBottom: 8,
},
]}
>
{children}
</Animated.View>
);
};
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<import('react-native').ScrollView | null>(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<number | null>(null);
const [activeCalories, setActiveCalories] = 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({
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<NutritionSummary | null>(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<string | null>(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 (
<View style={styles.container}>
<LinearGradient
colors={backgroundGradientColors}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<SafeAreaView style={styles.safeArea}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: bottomPadding }}
showsVerticalScrollIndicator={false}
>
{/* 体重历史记录卡片 */}
<Text style={styles.sectionTitle}></Text>
<WeightHistoryCard />
{/* 标题与日期选择 */}
<Text style={styles.monthTitle}>{monthTitle}</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.daysContainer}
ref={daysScrollRef}
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
>
{days.map((d, i) => {
const selected = i === selectedIndex;
const isFutureDate = d.date.isAfter(dayjs(), 'day');
return (
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
<TouchableOpacity
style={[
styles.dayPill,
selected ? styles.dayPillSelected : styles.dayPillNormal,
isFutureDate && styles.dayPillDisabled
]}
onPress={() => !isFutureDate && onSelectDate(i)}
activeOpacity={isFutureDate ? 1 : 0.8}
disabled={isFutureDate}
>
<Text style={[
styles.dayLabel,
selected && styles.dayLabelSelected,
isFutureDate && styles.dayLabelDisabled
]}> {d.weekdayZh} </Text>
<Text style={[
styles.dayDate,
selected && styles.dayDateSelected,
isFutureDate && styles.dayDateDisabled
]}>{d.dayOfMonth}</Text>
</TouchableOpacity>
{selected && <View style={styles.selectedDot} />}
</View>
);
})}
</ScrollView>
{/* 营养摄入雷达图卡片 */}
<NutritionRadarCard
nutritionSummary={nutritionSummary}
/>
{/* 真正瀑布流布局 */}
<View style={styles.masonryContainer}>
{/* 左列 */}
<View style={styles.masonryColumn}>
<FloatingCard style={styles.masonryCard} delay={0}>
<StressMeter
value={hrvValue}
updateTime={hrvUpdateTime}
hrvValue={hrvValue}
/>
</FloatingCard>
<FloatingCard style={[styles.masonryCard, styles.caloriesCard]} delay={500}>
<Text style={styles.cardTitleSecondary}></Text>
{activeCalories != null ? (
<AnimatedNumber
value={activeCalories}
resetToken={animToken}
style={styles.caloriesValue}
format={(v) => `${Math.round(v)} 千卡`}
/>
) : (
<Text style={styles.caloriesValue}></Text>
)}
</FloatingCard>
<FloatingCard style={[styles.masonryCard, styles.stepsCard]} delay={1000}>
<View style={styles.cardHeaderRow}>
<Text style={styles.cardTitle}></Text>
</View>
{stepCount != null ? (
<AnimatedNumber
value={stepCount}
resetToken={animToken}
style={styles.stepsValue}
format={(v) => `${Math.round(v)}/${stepGoal}`}
/>
) : (
<Text style={styles.stepsValue}>/{stepGoal}</Text>
)}
<ProgressBar
progress={Math.min(1, Math.max(0, (stepCount ?? 0) / stepGoal))}
height={16}
trackColor="#FFEBCB"
fillColor="#FFC365"
showLabel={false}
/>
</FloatingCard>
{/* 心情卡片 */}
<FloatingCard style={[styles.masonryCard, styles.moodCard]} delay={1500}>
<MoodCard
moodCheckin={currentMoodCheckin}
onPress={() => router.push('/mood/calendar')}
isLoading={isMoodLoading}
/>
</FloatingCard>
</View>
{/* 右列 */}
<View style={styles.masonryColumn}>
<FloatingCard style={styles.masonryCard} delay={250}>
<BMICard
weight={userProfile?.weight ? parseFloat(userProfile.weight) : undefined}
height={userProfile?.height ? parseFloat(userProfile.height) : undefined}
style={styles.bmiCardOverride}
/>
</FloatingCard>
<FloatingCard style={styles.masonryCard} delay={750}>
<FitnessRingsCard
activeCalories={fitnessRingsData.activeCalories}
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
exerciseMinutes={fitnessRingsData.exerciseMinutes}
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
standHours={fitnessRingsData.standHours}
standHoursGoal={fitnessRingsData.standHoursGoal}
resetToken={animToken}
/>
</FloatingCard>
<FloatingCard style={[styles.masonryCard, styles.sleepCard]} delay={1250}>
<View style={styles.cardHeaderRow}>
<Text style={styles.cardTitle}></Text>
</View>
{sleepDuration != null ? (
<Text style={styles.sleepValue}>
{Math.floor(sleepDuration / 60)}{Math.floor(sleepDuration % 60)}
</Text>
) : (
<Text style={styles.sleepValue}></Text>
)}
</FloatingCard>
</View>
</View>
</ScrollView>
</SafeAreaView>
</View>
);
}
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,
},
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',
},
});