Files
digital-pilates/components/WaterIntakeCard.tsx

360 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 { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount } from '@/utils/userPreferences';
import dayjs from 'dayjs';
import * as Haptics from 'expo-haptics';
import LottieView from 'lottie-react-native';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle
} from 'react-native';
import AddWaterModal from './AddWaterModal';
import { AnimatedNumber } from './AnimatedNumber';
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 [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;
// 加载用户偏好的快速添加饮水默认值
useEffect(() => {
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 () => {
// 触发震动反馈
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
animationRef.current?.play();
// 使用用户配置的快速添加饮水量
const waterAmount = quickWaterAmount;
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
const recordedAt = selectedDate ? dayjs(selectedDate).toISOString() : dayjs().toISOString();
await addWaterRecord(waterAmount, recordedAt);
};
// 处理卡片点击 - 打开配置饮水弹窗
const handleCardPress = () => {
// 触发震动反馈
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
setIsModalVisible(true);
};
// 处理关闭弹窗
const handleCloseModal = async () => {
setIsModalVisible(false);
// 弹窗关闭后重新加载快速添加默认值,以防用户修改了设置
try {
const amount = await getQuickWaterAmount();
setQuickWaterAmount(amount);
} catch (error) {
console.error('刷新快速添加默认值失败:', error);
}
};
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}>
<Text style={styles.title}></Text>
{isToday && (
<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;
// 动画变换高度从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>
{/* 配置饮水弹窗 */}
<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,
minHeight: 22,
},
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,
},
});
export default WaterIntakeCard;