feat: 支持饮水记录卡片
This commit is contained in:
377
components/AddWaterModal.tsx
Normal file
377
components/AddWaterModal.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
interface AddWaterModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
selectedDate?: string; // 新增:选中的日期,格式为 YYYY-MM-DD
|
||||
}
|
||||
|
||||
interface TabButtonProps {
|
||||
title: string;
|
||||
isActive: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const TabButton: React.FC<TabButtonProps> = ({ title, isActive, onPress }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.tabButton, isActive && styles.activeTabButton]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text style={[styles.tabButtonText, isActive && styles.activeTabButtonText]}>
|
||||
{title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const AddWaterModal: React.FC<AddWaterModalProps> = ({ visible, onClose, selectedDate }) => {
|
||||
const [activeTab, setActiveTab] = useState<'add' | 'goal'>('add');
|
||||
const [waterAmount, setWaterAmount] = useState<string>('250');
|
||||
const [note, setNote] = useState<string>('');
|
||||
const [dailyGoal, setDailyGoal] = useState<string>('2000');
|
||||
|
||||
// 使用新的 hook 来处理指定日期的饮水数据
|
||||
const { addWaterRecord, updateWaterGoal } = useWaterDataByDate(selectedDate);
|
||||
|
||||
const quickAmounts = [100, 150, 200, 250, 300, 350, 400, 500];
|
||||
const goalPresets = [1500, 2000, 2500, 3000, 3500, 4000];
|
||||
|
||||
const handleAddWater = async () => {
|
||||
const amount = parseInt(waterAmount);
|
||||
if (amount > 0) {
|
||||
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
|
||||
const recordedAt = selectedDate ? dayjs(selectedDate).toISOString() : dayjs().toISOString();
|
||||
|
||||
const success = await addWaterRecord(amount, recordedAt);
|
||||
if (success) {
|
||||
setWaterAmount('250');
|
||||
setNote('');
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateGoal = async () => {
|
||||
const goal = parseInt(dailyGoal);
|
||||
if (goal >= 500 && goal <= 10000) {
|
||||
const success = await updateWaterGoal(goal);
|
||||
if (success) {
|
||||
setDailyGoal('2000');
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderAddRecordTab = () => (
|
||||
<View style={styles.tabContent}>
|
||||
<Text style={styles.sectionTitle}>饮水量 (ml)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={waterAmount}
|
||||
onChangeText={setWaterAmount}
|
||||
keyboardType="numeric"
|
||||
placeholder="请输入饮水量"
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
|
||||
<Text style={styles.sectionTitle}>快速选择</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.quickAmountsContainer}
|
||||
>
|
||||
<View style={styles.quickAmountsWrapper}>
|
||||
{quickAmounts.map((amount) => (
|
||||
<TouchableOpacity
|
||||
key={amount}
|
||||
style={[
|
||||
styles.quickAmountButton,
|
||||
parseInt(waterAmount) === amount && styles.quickAmountButtonActive
|
||||
]}
|
||||
onPress={() => setWaterAmount(amount.toString())}
|
||||
>
|
||||
<Text style={[
|
||||
styles.quickAmountText,
|
||||
parseInt(waterAmount) === amount && styles.quickAmountTextActive
|
||||
]}>
|
||||
{amount}ml
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<Text style={styles.sectionTitle}>备注 (可选)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.remarkInput]}
|
||||
value={note}
|
||||
onChangeText={setNote}
|
||||
placeholder="添加备注..."
|
||||
placeholderTextColor="#999"
|
||||
multiline
|
||||
/>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity style={[styles.button, styles.cancelButton]} onPress={onClose}>
|
||||
<Text style={styles.cancelButtonText}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.button, styles.confirmButton]} onPress={handleAddWater}>
|
||||
<Text style={styles.confirmButtonText}>添加记录</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderGoalTab = () => (
|
||||
<View style={styles.tabContent}>
|
||||
<Text style={styles.sectionTitle}>每日饮水目标 (ml)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={dailyGoal}
|
||||
onChangeText={setDailyGoal}
|
||||
keyboardType="numeric"
|
||||
placeholder="请输入每日饮水目标"
|
||||
placeholderTextColor="#999"
|
||||
/>
|
||||
|
||||
<Text style={styles.sectionTitle}>推荐目标</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.quickAmountsContainer}
|
||||
>
|
||||
<View style={styles.quickAmountsWrapper}>
|
||||
{goalPresets.map((goal) => (
|
||||
<TouchableOpacity
|
||||
key={goal}
|
||||
style={[
|
||||
styles.quickAmountButton,
|
||||
parseInt(dailyGoal) === goal && styles.quickAmountButtonActive
|
||||
]}
|
||||
onPress={() => setDailyGoal(goal.toString())}
|
||||
>
|
||||
<Text style={[
|
||||
styles.quickAmountText,
|
||||
parseInt(dailyGoal) === goal && styles.quickAmountTextActive
|
||||
]}>
|
||||
{goal}ml
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity style={[styles.button, styles.cancelButton]} onPress={onClose}>
|
||||
<Text style={styles.cancelButtonText}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.button, styles.confirmButton]} onPress={handleUpdateGoal}>
|
||||
<Text style={styles.confirmButtonText}>更新目标</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.centeredView}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<View style={styles.modalView}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.modalTitle}>配置饮水</Text>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<Ionicons name="close" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.tabContainer}>
|
||||
<TabButton
|
||||
title="添加记录"
|
||||
isActive={activeTab === 'add'}
|
||||
onPress={() => setActiveTab('add')}
|
||||
/>
|
||||
<TabButton
|
||||
title="设置目标"
|
||||
isActive={activeTab === 'goal'}
|
||||
onPress={() => setActiveTab('goal')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.contentScrollView}>
|
||||
{activeTab === 'add' ? renderAddRecordTab() : renderGoalTab()}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
centeredView: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
modalView: {
|
||||
width: '90%',
|
||||
maxWidth: 350,
|
||||
maxHeight: '80%',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 5,
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: 4,
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
},
|
||||
activeTabButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
tabButtonText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeTabButtonText: {
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
},
|
||||
contentScrollView: {
|
||||
maxHeight: 400,
|
||||
},
|
||||
tabContent: {
|
||||
paddingVertical: 10,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 10,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
marginBottom: 15,
|
||||
},
|
||||
remarkInput: {
|
||||
height: 80,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
quickAmountsContainer: {
|
||||
marginBottom: 15,
|
||||
},
|
||||
quickAmountsWrapper: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
paddingRight: 10,
|
||||
},
|
||||
quickAmountButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
backgroundColor: '#f9f9f9',
|
||||
minWidth: 60,
|
||||
alignItems: 'center',
|
||||
},
|
||||
quickAmountButtonActive: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderColor: '#007AFF',
|
||||
},
|
||||
quickAmountText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
fontWeight: '500',
|
||||
},
|
||||
quickAmountTextActive: {
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginTop: 20,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
fontWeight: '500',
|
||||
},
|
||||
confirmButtonText: {
|
||||
fontSize: 16,
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default AddWaterModal;
|
||||
@@ -12,36 +12,71 @@ type AnimatedNumberProps = {
|
||||
|
||||
export function AnimatedNumber({
|
||||
value,
|
||||
durationMs = 800,
|
||||
durationMs = 300,
|
||||
format,
|
||||
style,
|
||||
resetToken,
|
||||
}: AnimatedNumberProps) {
|
||||
const animated = useRef(new Animated.Value(0)).current;
|
||||
const opacity = useRef(new Animated.Value(1)).current;
|
||||
const [display, setDisplay] = useState<string>('0');
|
||||
const [currentValue, setCurrentValue] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
animated.stopAnimation(() => {
|
||||
animated.setValue(0);
|
||||
Animated.timing(animated, {
|
||||
toValue: value,
|
||||
duration: durationMs,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
// 如果值没有变化,不执行动画
|
||||
if (value === currentValue && resetToken === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 停止当前动画
|
||||
opacity.stopAnimation(() => {
|
||||
// 创建优雅的透明度变化动画
|
||||
const fadeOut = Animated.timing(opacity, {
|
||||
toValue: 0.2, // 淡出到较低透明度
|
||||
duration: durationMs * 0.4, // 淡出占总时长的40%
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
});
|
||||
|
||||
const fadeIn = Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration: durationMs * 0.6, // 淡入占总时长的60%
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: false,
|
||||
});
|
||||
|
||||
// 在淡出完成时更新数字显示
|
||||
fadeOut.start(() => {
|
||||
// 更新当前值和显示
|
||||
setCurrentValue(value);
|
||||
setDisplay(format ? format(value) : `${Math.round(value)}`);
|
||||
|
||||
// 然后淡入新数字
|
||||
fadeIn.start();
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value, resetToken]);
|
||||
|
||||
// 初始化显示值
|
||||
useEffect(() => {
|
||||
const id = animated.addListener(({ value: v }) => {
|
||||
const num = Number(v) || 0;
|
||||
setDisplay(format ? format(num) : `${Math.round(num)}`);
|
||||
});
|
||||
return () => animated.removeListener(id);
|
||||
}, [animated, format]);
|
||||
if (currentValue !== value) {
|
||||
setCurrentValue(value);
|
||||
setDisplay(format ? format(value) : `${Math.round(value)}`);
|
||||
}
|
||||
}, [value, format, currentValue]);
|
||||
|
||||
return <Animated.Text style={style}>{display}</Animated.Text>;
|
||||
return (
|
||||
<Animated.Text
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
opacity: opacity
|
||||
}
|
||||
]}
|
||||
>
|
||||
{display}
|
||||
</Animated.Text>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useMemo, useRef, useEffect } from 'react';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
ViewStyle,
|
||||
Animated
|
||||
ViewStyle
|
||||
} from 'react-native';
|
||||
|
||||
import { HourlyStepData } from '@/utils/health';
|
||||
import { AnimatedNumber } from './AnimatedNumber';
|
||||
// 使用原生View来替代SVG,避免导入问题
|
||||
// import Svg, { Rect } from 'react-native-svg';
|
||||
|
||||
@@ -53,7 +54,7 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
if (chartData && chartData.length > 0) {
|
||||
// 重置所有动画值
|
||||
animatedValues.forEach(animValue => animValue.setValue(0));
|
||||
|
||||
|
||||
// 同时启动所有柱体的弹性动画,有步数的柱体才执行动画
|
||||
chartData.forEach((data, index) => {
|
||||
if (data.steps > 0) {
|
||||
@@ -108,7 +109,7 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
{/* 数据柱体 - 只有当有数据时才显示并执行动画 */}
|
||||
{isActive && (
|
||||
<Animated.View
|
||||
@@ -132,9 +133,12 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
|
||||
{/* 步数和目标显示 */}
|
||||
<View style={styles.statsContainer}>
|
||||
<Text style={styles.stepCount}>
|
||||
{stepCount !== null ? stepCount.toLocaleString() : '——'}
|
||||
</Text>
|
||||
<AnimatedNumber
|
||||
value={stepCount || 0}
|
||||
style={styles.stepCount}
|
||||
format={(v) => stepCount !== null ? `${Math.round(v)}` : '——'}
|
||||
resetToken={stepCount}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
312
components/WaterIntakeCard.tsx
Normal file
312
components/WaterIntakeCard.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user