feat: 支持饮水记录卡片

This commit is contained in:
richarjiang
2025-09-02 15:50:35 +08:00
parent ed694f6142
commit 85a3c742df
16 changed files with 2066 additions and 56 deletions

View File

@@ -0,0 +1,312 @@
import { useWaterDataByDate } from '@/hooks/useWaterData';
import dayjs from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react';
import {
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle
} from 'react-native';
import AddWaterModal from './AddWaterModal';
interface WaterIntakeCardProps {
style?: ViewStyle;
selectedDate?: string; // 新增:选中的日期,格式为 YYYY-MM-DD
}
const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
style,
selectedDate
}) => {
const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord } = useWaterDataByDate(selectedDate);
const [isModalVisible, setIsModalVisible] = useState(false);
// 计算当前饮水量和目标
const currentIntake = waterStats?.totalAmount || 0;
const targetIntake = dailyWaterGoal || 2000;
// 为每个时间点创建独立的动画值
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;
const currentHour = isToday ? new Date().getHours() : -1; // 如果不是今天,设为-1表示没有当前小时
// 触发柱体动画
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 () => {
// 默认添加250ml水
const waterAmount = 250;
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
const recordedAt = selectedDate ? dayjs(selectedDate).toISOString() : dayjs().toISOString();
await addWaterRecord(waterAmount, recordedAt);
};
// 处理卡片点击 - 打开配置饮水弹窗
const handleCardPress = () => {
setIsModalVisible(true);
};
// 处理关闭弹窗
const handleCloseModal = () => {
setIsModalVisible(false);
};
return (
<>
<TouchableOpacity
style={[styles.container, style]}
onPress={handleCardPress}
activeOpacity={0.8}
>
{/* 标题和加号按钮 */}
<View style={styles.header}>
<Text style={styles.title}></Text>
<TouchableOpacity style={styles.addButton} onPress={handleQuickAddWater}>
<Text style={styles.addButtonText}>+</Text>
</TouchableOpacity>
</View>
{/* 柱状图 */}
<View style={styles.chartContainer}>
<View style={styles.chartWrapper}>
<View style={styles.chartArea}>
{chartData.map((data, index) => {
// 判断是否是当前小时或者有活动的小时
const isActive = data.amount > 0;
const isCurrent = isToday && index <= currentHour;
// 动画变换高度从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}>
<Text style={styles.currentIntake}>
{currentIntake !== null ? `${currentIntake}ml` : '——'}
</Text>
<Text style={styles.targetIntake}>
/ {targetIntake}ml
</Text>
</View>
{/* 完成率显示 */}
{waterStats && (
<View style={styles.completionContainer}>
<Text style={styles.completionText}>
{Math.round(waterStats.completionRate)}%
</Text>
</View>
)}
</TouchableOpacity>
{/* 配置饮水弹窗 */}
<AddWaterModal
visible={isModalVisible}
onClose={handleCloseModal}
selectedDate={selectedDate}
/>
</>
);
};
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,
},
title: {
fontSize: 14,
color: '#192126',
fontWeight: '500',
},
addButton: {
width: 22,
height: 22,
borderRadius: 16,
backgroundColor: '#E1E7FF',
alignItems: 'center',
justifyContent: 'center',
},
addButtonText: {
fontSize: 14,
color: '#6366F1',
fontWeight: '700',
lineHeight: 14,
},
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,
},
completionContainer: {
alignItems: 'flex-start',
marginTop: 2,
},
completionText: {
fontSize: 12,
color: '#10B981',
fontWeight: '500',
},
});
export default WaterIntakeCard;