- 新增饮水详情页面 `/water/detail` 展示每日饮水记录与统计 - 新增饮水设置页面 `/water/settings` 支持目标与快速添加配置 - 新增喝水提醒设置页面 `/water/reminder-settings` 支持自定义时间段与间隔 - 重构 `useWaterData` Hook,支持按日期查询与实时刷新 - 新增 `WaterNotificationHelpers.scheduleCustomWaterReminders` 实现个性化提醒 - 优化心情编辑页键盘体验,新增 `KeyboardAvoidingView` 与滚动逻辑 - 升级版本号至 1.0.14 并补充路由常量 - 补充用户偏好存储字段 `waterReminderEnabled/startTime/endTime/interval` - 废弃后台定时任务中的旧版喝水提醒逻辑,改为用户手动管理
368 lines
10 KiB
TypeScript
368 lines
10 KiB
TypeScript
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 { waterStats, dailyWaterGoal, waterRecords, addWaterRecord, getWaterRecordsByDate } = 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 loadDataOnFocus = async () => {
|
||
try {
|
||
// 重新加载快速添加饮水默认值
|
||
const amount = await getQuickWaterAmount();
|
||
setQuickWaterAmount(amount);
|
||
|
||
// 重新获取水数据以刷新显示
|
||
const targetDate = selectedDate || dayjs().format('YYYY-MM-DD');
|
||
await getWaterRecordsByDate(targetDate);
|
||
} catch (error) {
|
||
console.error('页面聚焦时加载数据失败:', error);
|
||
}
|
||
};
|
||
|
||
loadDataOnFocus();
|
||
}, [selectedDate, getWaterRecordsByDate])
|
||
);
|
||
|
||
// 触发柱体动画
|
||
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 = dayjs().toISOString()
|
||
await addWaterRecord(waterAmount, recordedAt);
|
||
};
|
||
|
||
// 处理卡片点击 - 跳转到饮水详情页面
|
||
const handleCardPress = async () => {
|
||
// 触发震动反馈
|
||
if (process.env.EXPO_OS === 'ios') {
|
||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||
}
|
||
|
||
// 跳转到饮水详情页面,传递选中的日期参数
|
||
router.push({
|
||
pathname: '/water/detail',
|
||
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; |