Files
digital-pilates/components/WaterIntakeCard.tsx
richarjiang e6dfd4d59a feat(health): 重构营养卡片数据获取逻辑,支持基础代谢与运动消耗分离
- 新增 fetchCompleteNutritionCardData 异步 action,统一拉取营养、健康与基础代谢数据
- NutritionRadarCard 改用 Redux 数据源,移除 props 透传,自动根据日期刷新
- BasalMetabolismCard 新增详情弹窗,展示 BMR 计算公式、正常区间及提升策略
- StepsCard 与 StepsCardOptimized 引入 InteractionManager 与动画懒加载,减少 UI 阻塞
- HealthKitManager 新增饮水读写接口,支持将饮水记录同步至 HealthKit
- 移除 statistics 页面冗余 mock 与 nutrition/health 重复请求,缓存时间统一为 5 分钟
2025-09-23 10:01:50 +08:00

376 lines
10 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 { useAuthGuard } from '@/hooks/useAuthGuard';
import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount } from '@/utils/userPreferences';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
import LottieView from 'lottie-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle
} from 'react-native';
import { AnimatedNumber } from './AnimatedNumber';
interface WaterIntakeCardProps {
style?: ViewStyle;
selectedDate?: string; // 新增:选中的日期,格式为 YYYY-MM-DD
}
const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
style,
selectedDate
}) => {
const router = useRouter();
const { ensureLoggedIn } = useAuthGuard();
const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord } = useWaterDataByDate(selectedDate);
const [quickWaterAmount, setQuickWaterAmount] = useState(150); // 默认值,将从用户偏好中加载
// 计算当前饮水量和目标
const currentIntake = waterStats?.totalAmount || 0;
const targetIntake = dailyWaterGoal || 2000;
const animationRef = useRef<LottieView>(null);
// 为每个时间点创建独立的动画值
const animatedValues = useMemo(() =>
Array.from({ length: 24 }, () => new Animated.Value(0))
, []);
// 计算柱状图数据
const chartData = useMemo(() => {
if (!waterRecords || waterRecords.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, amount: 0, height: 0 }));
}
// 按小时分组数据
const hourlyData: { hour: number; amount: number }[] = Array.from({ length: 24 }, (_, i) => ({
hour: i,
amount: 0,
}));
waterRecords.forEach(record => {
// 优先使用 recordedAt如果没有则使用 createdAt
const dateTime = record.recordedAt || record.createdAt;
const hour = dayjs(dateTime).hour();
if (hour >= 0 && hour < 24) {
hourlyData[hour].amount += record.amount;
}
});
// 找到最大饮水量用于计算高度比例
const maxAmount = Math.max(...hourlyData.map(data => data.amount), 1);
const maxHeight = 20; // 柱状图最大高度
return hourlyData.map(data => ({
hour: data.hour,
amount: data.amount,
height: maxAmount > 0 ? (data.amount / maxAmount) * maxHeight : 0
}));
}, [waterRecords]);
// 判断是否是今天
const isToday = selectedDate === dayjs().format('YYYY-MM-DD') || !selectedDate;
// 加载用户偏好的快速添加饮水默认值
useFocusEffect(
useCallback(() => {
const loadQuickWaterAmount = async () => {
try {
const amount = await getQuickWaterAmount();
setQuickWaterAmount(amount);
} catch (error) {
console.error('加载快速添加饮水默认值失败:', error);
// 保持默认值 250ml
}
};
loadQuickWaterAmount();
}, [])
);
// 触发柱体动画
useEffect(() => {
if (chartData && chartData.length > 0) {
// 重置所有动画值
animatedValues.forEach(animValue => animValue.setValue(0));
// 找出所有有饮水记录的柱体索引
const activeBarIndices = chartData
.map((data, index) => ({ data, index }))
.filter(item => item.data.amount > 0)
.map(item => item.index);
// 依次执行动画每个柱体间隔100ms
activeBarIndices.forEach((barIndex, sequenceIndex) => {
setTimeout(() => {
Animated.spring(animatedValues[barIndex], {
toValue: 1,
tension: 150,
friction: 8,
useNativeDriver: false,
}).start();
}, sequenceIndex * 100); // 每个柱体延迟100ms
});
}
}, [chartData, animatedValues]);
// 处理添加喝水 - 右上角按钮直接添加
const handleQuickAddWater = async () => {
// 检查用户是否已登录
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
return;
}
// 触发震动反馈
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
animationRef.current?.play();
// 使用用户配置的快速添加饮水量
const waterAmount = quickWaterAmount;
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
const recordedAt = dayjs().toISOString()
await addWaterRecord(waterAmount, recordedAt);
};
// 处理卡片点击 - 跳转到饮水设置页面
const handleCardPress = async () => {
// 检查用户是否已登录
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
return;
}
// 触发震动反馈
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
// 跳转到饮水设置页面,传递选中的日期参数
router.push({
pathname: '/water-settings',
params: selectedDate ? { selectedDate } : undefined
});
};
return (
<TouchableOpacity
style={[styles.container, style]}
onPress={handleCardPress}
activeOpacity={0.8}
>
<LottieView
ref={animationRef}
autoPlay={false}
loop={false}
source={require('@/assets/lottie/Confetti.json')}
style={{
width: 150,
height: 150,
position: 'absolute',
left: '15%',
}}
/>
{/* 标题和加号按钮 */}
<View style={styles.header}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
}}>
<Image
source={require('@/assets/images/icons/IconGlass.png')}
style={styles.titleIcon}
/>
<Text style={styles.title}></Text>
</View>
{isToday && (
<TouchableOpacity style={styles.addButton} onPress={handleQuickAddWater}>
<Text style={styles.addButtonText}>+ {quickWaterAmount}ml</Text>
</TouchableOpacity>
)}
</View>
{/* 柱状图 */}
<View style={styles.chartContainer}>
<View style={styles.chartWrapper}>
<View style={styles.chartArea}>
{chartData.map((data, index) => {
// 判断是否有活动的小时
const isActive = data.amount > 0;
// 动画变换高度从0到目标高度
const animatedHeight = animatedValues[index].interpolate({
inputRange: [0, 1],
outputRange: [0, data.height],
});
// 动画变换透明度从0到1保持柱体在动画过程中可见
const animatedOpacity = animatedValues[index].interpolate({
inputRange: [0, 0.1, 1],
outputRange: [0, 1, 1],
});
return (
<View key={`bar-container-${index}`} style={styles.barContainer}>
{/* 背景柱体 - 始终显示,使用蓝色系的淡色 */}
<View
style={[
styles.chartBar,
{
height: 20, // 背景柱体占满整个高度
backgroundColor: '#F0F9FF', // 蓝色系淡色
}
]}
/>
{/* 数据柱体 - 只有当有数据时才显示并执行动画 */}
{isActive && (
<Animated.View
style={[
styles.chartBar,
{
height: animatedHeight,
backgroundColor: '#7DD3FC', // 蓝色系
opacity: animatedOpacity,
}
]}
/>
)}
</View>
);
})}
</View>
</View>
</View>
{/* 饮水量显示 */}
<View style={styles.statsContainer}>
{currentIntake !== null ? (
<AnimatedNumber
value={currentIntake}
style={styles.currentIntake}
format={(value) => `${Math.round(value)}ml`}
resetToken={selectedDate}
/>
) : (
<Text style={styles.currentIntake}></Text>
)}
<Text style={styles.targetIntake}>
/ {targetIntake}ml
</Text>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'space-between',
borderRadius: 20,
padding: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.08,
shadowRadius: 20,
elevation: 8,
backgroundColor: '#FFFFFF',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
minHeight: 22,
},
titleIcon: {
width: 16,
height: 16,
marginRight: 6,
resizeMode: 'contain',
},
title: {
fontSize: 14,
color: '#192126',
fontWeight: '600',
},
addButton: {
borderRadius: 16,
backgroundColor: '#E1E7FF',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 6,
paddingVertical: 5,
},
addButtonText: {
fontSize: 10,
color: '#6366F1',
fontWeight: '700',
lineHeight: 10,
},
chartContainer: {
flex: 1,
justifyContent: 'center',
},
chartWrapper: {
width: '100%',
alignItems: 'center',
},
chartArea: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 20,
width: '100%',
maxWidth: 240,
justifyContent: 'space-between',
paddingHorizontal: 4,
},
barContainer: {
width: 4,
height: 20,
alignItems: 'center',
justifyContent: 'flex-end',
position: 'relative',
},
chartBar: {
width: 4,
borderRadius: 1,
position: 'absolute',
bottom: 0,
},
statsContainer: {
flexDirection: 'row',
alignItems: 'baseline',
marginTop: 6,
},
currentIntake: {
fontSize: 14,
fontWeight: '600',
color: '#192126',
},
targetIntake: {
fontSize: 12,
color: '#6B7280',
marginLeft: 4,
},
});
export default WaterIntakeCard;