diff --git a/assets/images/icons/IconGlass.png b/assets/images/icons/IconGlass.png new file mode 100644 index 0000000..49a179f Binary files /dev/null and b/assets/images/icons/IconGlass.png differ diff --git a/components/AddWaterModal.tsx b/components/AddWaterModal.tsx index 4d948b6..ffa2a05 100644 --- a/components/AddWaterModal.tsx +++ b/components/AddWaterModal.tsx @@ -1,8 +1,13 @@ +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; import { useWaterDataByDate } from '@/hooks/useWaterData'; +import { getQuickWaterAmount, setQuickWaterAmount } from '@/utils/userPreferences'; import { Ionicons } from '@expo/vector-icons'; import dayjs from 'dayjs'; -import React, { useState } from 'react'; +import { Image } from 'expo-image'; +import React, { useEffect, useState } from 'react'; import { + Alert, KeyboardAvoidingView, Modal, Platform, @@ -13,6 +18,7 @@ import { TouchableOpacity, View, } from 'react-native'; +import { Swipeable } from 'react-native-gesture-handler'; interface AddWaterModalProps { visible: boolean; @@ -20,34 +26,22 @@ interface AddWaterModalProps { selectedDate?: string; // 新增:选中的日期,格式为 YYYY-MM-DD } -interface TabButtonProps { - title: string; - isActive: boolean; - onPress: () => void; -} - -const TabButton: React.FC = ({ title, isActive, onPress }) => ( - - - {title} - - -); - const AddWaterModal: React.FC = ({ visible, onClose, selectedDate }) => { - const [activeTab, setActiveTab] = useState<'add' | 'goal'>('add'); + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + + const [activeTab, setActiveTab] = useState<'manage' | 'records'>('manage'); const [waterAmount, setWaterAmount] = useState('250'); const [note, setNote] = useState(''); const [dailyGoal, setDailyGoal] = useState('2000'); + const [quickAddAmount, setQuickAddAmount] = useState('250'); // 快速添加默认值 // 使用新的 hook 来处理指定日期的饮水数据 - const { addWaterRecord, updateWaterGoal } = useWaterDataByDate(selectedDate); + const { waterRecords, dailyWaterGoal, addWaterRecord, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate); const quickAmounts = [100, 150, 200, 250, 300, 350, 400, 500]; const goalPresets = [1500, 2000, 2500, 3000, 3500, 4000]; + const quickAddPresets = [100, 150, 200, 250, 300, 350, 400, 500]; // 快速添加默认值选项 const handleAddWater = async () => { const amount = parseInt(waterAmount); @@ -75,79 +69,122 @@ const AddWaterModal: React.FC = ({ visible, onClose, selecte } }; - const renderAddRecordTab = () => ( - - 饮水量 (ml) - + // 处理保存所有设置(饮水目标 + 快速添加默认值) + const handleSaveSettings = async () => { + const goal = parseInt(dailyGoal); + const amount = parseInt(quickAddAmount); + + let hasError = false; + + // 验证饮水目标 + if (goal < 500 || goal > 10000) { + Alert.alert('输入错误', '每日饮水目标应在500ml到10000ml之间'); + return; + } + + // 验证快速添加默认值 + if (amount < 50 || amount > 1000) { + Alert.alert('输入错误', '快速添加默认值应在50ml到1000ml之间'); + return; + } + + try { + // 保存饮水目标 + const goalSuccess = await updateWaterGoal(goal); + if (!goalSuccess) { + hasError = true; + } + + // 保存快速添加默认值 + await setQuickWaterAmount(amount); + + if (!hasError) { + onClose(); + } + } catch (error) { + Alert.alert('设置失败', '无法保存设置,请重试'); + } + }; - 快速选择 - { + await removeWaterRecord(recordId); + }; + + // 加载用户偏好设置和当前饮水目标 + useEffect(() => { + const loadUserPreferences = async () => { + try { + const amount = await getQuickWaterAmount(); + setQuickAddAmount(amount.toString()); + + // 设置当前的饮水目标 + if (dailyWaterGoal) { + setDailyGoal(dailyWaterGoal.toString()); + } + } catch (error) { + console.error('加载用户偏好设置失败:', error); + } + }; + + if (visible) { + loadUserPreferences(); + } + }, [visible, dailyWaterGoal]); + + // 渲染Tab切换器 - 参照营养记录页面的实现 + const renderTabToggle = () => ( + + setActiveTab('manage')} > - - {quickAmounts.map((amount) => ( - setWaterAmount(amount.toString())} - > - - {amount}ml - - - ))} - - - - 备注 (可选) - - - - - 取消 - - - 添加记录 - - + + 饮水配置 + + + setActiveTab('records')} + > + + 饮水记录 + + ); - const renderGoalTab = () => ( + // 合并后的管理Tab,包含添加记录和设置目标 + const renderManageTab = () => ( - 每日饮水目标 (ml) + {/* 设置目标部分 */} + 每日饮水目标 (ml) - 推荐目标 + 推荐目标 = ({ visible, onClose, selecte key={goal} style={[ styles.quickAmountButton, - parseInt(dailyGoal) === goal && styles.quickAmountButtonActive + { + borderColor: colorTokens.border, + backgroundColor: colorTokens.pageBackgroundEmphasis + }, + parseInt(dailyGoal) === goal && { + backgroundColor: colorTokens.primary, + borderColor: colorTokens.primary + } ]} onPress={() => setDailyGoal(goal.toString())} > {goal}ml @@ -174,20 +218,195 @@ const AddWaterModal: React.FC = ({ visible, onClose, selecte + {/* 快速添加默认值设置部分 */} + 快速添加默认值 (ml) + + 设置点击右上角"+"按钮时添加的默认饮水量 + + + + 推荐设置 + + + {quickAddPresets.map((amount) => ( + setQuickAddAmount(amount.toString())} + > + + {amount}ml + + + ))} + + + - - 取消 + + 取消 - - 更新目标 + + 保存设置 ); + // 新增:饮水记录卡片组件 + const WaterRecordCard = ({ record, onDelete }: { record: any; onDelete: () => void }) => { + const swipeableRef = React.useRef(null); + + // 处理删除操作 + const handleDelete = () => { + Alert.alert( + '确认删除', + '确定要删除这条饮水记录吗?此操作无法撤销。', + [ + { + text: '取消', + style: 'cancel', + }, + { + text: '删除', + style: 'destructive', + onPress: () => { + onDelete(); + swipeableRef.current?.close(); + }, + }, + ] + ); + }; + + // 渲染右侧删除按钮 + const renderRightActions = () => { + return ( + + + 删除 + + ); + }; + + return ( + + + + + + + + + + + + + {dayjs(record.recordedAt || record.createdAt).format('HH:mm')} + + + + + {record.amount}ml + + + {record.note && ( + {record.note} + )} + + + + ); + }; + + // 新增:饮水记录Tab内容 + const renderRecordsTab = () => ( + + + {selectedDate ? dayjs(selectedDate).format('MM月DD日') : '今日'}饮水记录 + + + {waterRecords && waterRecords.length > 0 ? ( + + {waterRecords.map((record) => ( + handleDeleteRecord(record.id)} + /> + ))} + + {/* 总计显示 */} + + + 总计:{waterRecords.reduce((sum, record) => sum + record.amount, 0)}ml + + + 目标:{dailyWaterGoal}ml + + + + ) : ( + + + 暂无饮水记录 + 点击"添加记录"开始记录饮水量 + + )} + + ); + return ( = ({ visible, onClose, selecte style={styles.centeredView} behavior={Platform.OS === 'ios' ? 'padding' : 'height'} > - + - 配置饮水 + 配置饮水 - + - - setActiveTab('add')} - /> - setActiveTab('goal')} - /> - + {renderTabToggle()} - {activeTab === 'add' ? renderAddRecordTab() : renderGoalTab()} + {activeTab === 'manage' ? renderManageTab() : renderRecordsTab()} @@ -235,19 +443,20 @@ const styles = StyleSheet.create({ }, modalView: { width: '90%', - maxWidth: 350, - maxHeight: '80%', - backgroundColor: 'white', - borderRadius: 20, - padding: 20, - shadowColor: '#000', + maxWidth: 400, + height: 650, // 固定高度 + borderRadius: 24, + paddingTop: 24, + paddingHorizontal: 20, + paddingBottom: 20, + shadowColor: '#000000', shadowOffset: { width: 0, - height: 2, + height: 8, }, - shadowOpacity: 0.25, - shadowRadius: 4, - elevation: 5, + shadowOpacity: 0.12, + shadowRadius: 20, + elevation: 8, }, header: { flexDirection: 'row', @@ -256,59 +465,55 @@ const styles = StyleSheet.create({ marginBottom: 20, }, modalTitle: { - fontSize: 20, - fontWeight: 'bold', - color: '#333', + fontSize: 16, + fontWeight: '600', + letterSpacing: -0.5, }, closeButton: { padding: 5, }, tabContainer: { flexDirection: 'row', - marginBottom: 20, - borderRadius: 10, - backgroundColor: '#f5f5f5', - padding: 4, + borderRadius: 20, + padding: 2, + marginBottom: 24, }, tabButton: { - flex: 1, - paddingVertical: 10, + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 18, + minWidth: 80, alignItems: 'center', - borderRadius: 8, - }, - activeTabButton: { - backgroundColor: '#007AFF', + flex: 1, }, tabButtonText: { fontSize: 14, - color: '#666', - fontWeight: '500', - }, - activeTabButtonText: { - color: 'white', fontWeight: '600', }, contentScrollView: { - maxHeight: 400, + flex: 1, }, tabContent: { paddingVertical: 10, }, sectionTitle: { - fontSize: 16, - fontWeight: '600', - color: '#333', - marginBottom: 10, + fontSize: 14, + fontWeight: '500', + marginBottom: 12, + letterSpacing: -0.3, + }, + sectionSubtitle: { + fontSize: 14, + fontWeight: '400', + lineHeight: 18, }, input: { - borderWidth: 1, - borderColor: '#e0e0e0', - borderRadius: 10, - paddingHorizontal: 15, - paddingVertical: 12, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, fontSize: 16, - color: '#333', - marginBottom: 15, + fontWeight: '500', + marginBottom: 16, }, remarkInput: { height: 80, @@ -324,27 +529,20 @@ const styles = StyleSheet.create({ }, quickAmountButton: { paddingHorizontal: 20, - paddingVertical: 10, + paddingVertical: 8, borderRadius: 20, - borderWidth: 1, - borderColor: '#e0e0e0', - backgroundColor: '#f9f9f9', - minWidth: 60, + minWidth: 70, alignItems: 'center', - }, - quickAmountButtonActive: { - backgroundColor: '#007AFF', - borderColor: '#007AFF', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, }, quickAmountText: { - fontSize: 14, - color: '#666', + fontSize: 15, fontWeight: '500', }, - quickAmountTextActive: { - color: 'white', - fontWeight: '600', - }, buttonContainer: { flexDirection: 'row', gap: 10, @@ -352,25 +550,134 @@ const styles = StyleSheet.create({ }, button: { flex: 1, - paddingVertical: 12, - borderRadius: 10, + paddingVertical: 14, + borderRadius: 12, alignItems: 'center', }, cancelButton: { - backgroundColor: '#f5f5f5', + }, confirmButton: { - backgroundColor: '#007AFF', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 3, }, cancelButtonText: { fontSize: 16, - color: '#666', - fontWeight: '500', + fontWeight: '600', }, confirmButtonText: { fontSize: 16, - color: 'white', + fontWeight: '700', + }, + // 饮水记录相关样式 + recordsList: { + gap: 12, + }, + recordCardContainer: { + // iOS 阴影效果 + shadowColor: '#000000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.08, + shadowRadius: 4, + // Android 阴影效果 + elevation: 2, + }, + recordCard: { + borderRadius: 12, + padding: 10, + }, + recordMainContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + recordIconContainer: { + width: 40, + height: 40, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + recordIcon: { + width: 20, + height: 20, + }, + recordInfo: { + flex: 1, + marginLeft: 12, + }, + recordLabel: { + fontSize: 16, fontWeight: '600', + marginBottom: 8, + }, + recordTimeContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + recordAmountContainer: { + alignItems: 'flex-end', + }, + recordAmount: { + fontSize: 14, + fontWeight: '500', + }, + deleteSwipeButton: { + backgroundColor: '#EF4444', + justifyContent: 'center', + alignItems: 'center', + width: 80, + borderRadius: 12, + marginLeft: 8, + }, + deleteSwipeButtonText: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: '600', + marginTop: 4, + }, + recordTimeText: { + fontSize: 12, + fontWeight: '400', + }, + recordNote: { + marginTop: 8, + fontSize: 14, + fontStyle: 'italic', + lineHeight: 20, + }, + recordsSummary: { + marginTop: 20, + padding: 16, + borderRadius: 12, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + summaryText: { + fontSize: 12, + fontWeight: '500', + }, + summaryGoal: { + fontSize: 12, + fontWeight: '500', + }, + noRecordsContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 40, + gap: 12, + }, + noRecordsText: { + fontSize: 16, + fontWeight: '600', + }, + noRecordsSubText: { + fontSize: 14, + textAlign: 'center', }, }); diff --git a/components/AnimatedNumber.tsx b/components/AnimatedNumber.tsx index 1f8eb4c..1c41233 100644 --- a/components/AnimatedNumber.tsx +++ b/components/AnimatedNumber.tsx @@ -18,15 +18,26 @@ export function AnimatedNumber({ resetToken, }: AnimatedNumberProps) { const opacity = useRef(new Animated.Value(1)).current; - const [display, setDisplay] = useState('0'); - const [currentValue, setCurrentValue] = useState(0); + const [display, setDisplay] = useState(() => + format ? format(value) : `${Math.round(value)}` + ); + const [currentValue, setCurrentValue] = useState(value); + const [lastResetToken, setLastResetToken] = useState(resetToken); + const [isAnimating, setIsAnimating] = useState(false); useEffect(() => { - // 如果值没有变化,不执行动画 - if (value === currentValue && resetToken === undefined) { + // 检查是否需要触发动画 + const valueChanged = value !== currentValue; + const resetTokenChanged = resetToken !== lastResetToken; + + // 如果值没有变化且resetToken也没有变化,或者正在动画中,则不执行新动画 + if ((!valueChanged && !resetTokenChanged) || isAnimating) { return; } + // 标记开始动画 + setIsAnimating(true); + // 停止当前动画 opacity.stopAnimation(() => { // 创建优雅的透明度变化动画 @@ -48,22 +59,17 @@ export function AnimatedNumber({ fadeOut.start(() => { // 更新当前值和显示 setCurrentValue(value); + setLastResetToken(resetToken); setDisplay(format ? format(value) : `${Math.round(value)}`); // 然后淡入新数字 - fadeIn.start(); + fadeIn.start(() => { + // 动画完成,标记结束 + setIsAnimating(false); + }); }); }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value, resetToken]); - - // 初始化显示值 - useEffect(() => { - if (currentValue !== value) { - setCurrentValue(value); - setDisplay(format ? format(value) : `${Math.round(value)}`); - } - }, [value, format, currentValue]); + }, [value, resetToken, currentValue, lastResetToken, isAnimating, durationMs, format]); return ( = ({ }) => { const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord } = useWaterDataByDate(selectedDate); const [isModalVisible, setIsModalVisible] = useState(false); + const [quickWaterAmount, setQuickWaterAmount] = useState(150); // 默认值,将从用户偏好中加载 // 计算当前饮水量和目标 const currentIntake = waterStats?.totalAmount || 0; @@ -64,9 +67,23 @@ const WaterIntakeCard: React.FC = ({ })); }, [waterRecords]); - // 获取当前小时 - 只有当选中的是今天时才显示当前小时 + // 判断是否是今天 const isToday = selectedDate === dayjs().format('YYYY-MM-DD') || !selectedDate; - const currentHour = isToday ? new Date().getHours() : -1; // 如果不是今天,设为-1表示没有当前小时 + + // 加载用户偏好的快速添加饮水默认值 + useEffect(() => { + const loadQuickWaterAmount = async () => { + try { + const amount = await getQuickWaterAmount(); + setQuickWaterAmount(amount); + } catch (error) { + console.error('加载快速添加饮水默认值失败:', error); + // 保持默认值 250ml + } + }; + + loadQuickWaterAmount(); + }, []); // 触发柱体动画 useEffect(() => { @@ -96,8 +113,8 @@ const WaterIntakeCard: React.FC = ({ // 处理添加喝水 - 右上角按钮直接添加 const handleQuickAddWater = async () => { - // 默认添加250ml水 - const waterAmount = 250; + // 使用用户配置的快速添加饮水量 + const waterAmount = quickWaterAmount; // 如果有选中日期,则为该日期添加记录;否则为今天添加记录 const recordedAt = selectedDate ? dayjs(selectedDate).toISOString() : dayjs().toISOString(); await addWaterRecord(waterAmount, recordedAt); @@ -109,8 +126,16 @@ const WaterIntakeCard: React.FC = ({ }; // 处理关闭弹窗 - const handleCloseModal = () => { + const handleCloseModal = async () => { setIsModalVisible(false); + + // 弹窗关闭后重新加载快速添加默认值,以防用户修改了设置 + try { + const amount = await getQuickWaterAmount(); + setQuickWaterAmount(amount); + } catch (error) { + console.error('刷新快速添加默认值失败:', error); + } }; return ( @@ -123,9 +148,11 @@ const WaterIntakeCard: React.FC = ({ {/* 标题和加号按钮 */} 喝水 - - + - + {isToday && ( + + + + + )} {/* 柱状图 */} @@ -133,9 +160,8 @@ const WaterIntakeCard: React.FC = ({ {chartData.map((data, index) => { - // 判断是否是当前小时或者有活动的小时 + // 判断是否有活动的小时 const isActive = data.amount > 0; - const isCurrent = isToday && index <= currentHour; // 动画变换:高度从0到目标高度 const animatedHeight = animatedValues[index].interpolate({ @@ -184,22 +210,21 @@ const WaterIntakeCard: React.FC = ({ {/* 饮水量显示 */} - - {currentIntake !== null ? `${currentIntake}ml` : '——'} - + {currentIntake !== null ? ( + `${Math.round(value)}ml`} + resetToken={selectedDate} + /> + ) : ( + —— + )} / {targetIntake}ml - {/* 完成率显示 */} - {waterStats && ( - - - {Math.round(waterStats.completionRate)}% - - - )} {/* 配置饮水弹窗 */} @@ -233,6 +258,7 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', alignItems: 'center', marginBottom: 4, + minHeight: 22, }, title: { fontSize: 14, @@ -298,15 +324,6 @@ const styles = StyleSheet.create({ color: '#6B7280', marginLeft: 4, }, - completionContainer: { - alignItems: 'flex-start', - marginTop: 2, - }, - completionText: { - fontSize: 12, - color: '#10B981', - fontWeight: '500', - }, }); export default WaterIntakeCard; \ No newline at end of file diff --git a/utils/userPreferences.ts b/utils/userPreferences.ts new file mode 100644 index 0000000..89d9a29 --- /dev/null +++ b/utils/userPreferences.ts @@ -0,0 +1,72 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// 用户偏好设置的存储键 +const PREFERENCES_KEYS = { + QUICK_WATER_AMOUNT: 'user_preference_quick_water_amount', +} as const; + +// 用户偏好设置接口 +export interface UserPreferences { + quickWaterAmount: number; +} + +// 默认的用户偏好设置 +const DEFAULT_PREFERENCES: UserPreferences = { + quickWaterAmount: 150, // 默认快速添加饮水量为 250ml +}; + +/** + * 获取用户偏好设置 + */ +export const getUserPreferences = async (): Promise => { + try { + const quickWaterAmount = await AsyncStorage.getItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT); + + return { + quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount, + }; + } catch (error) { + console.error('获取用户偏好设置失败:', error); + return DEFAULT_PREFERENCES; + } +}; + +/** + * 设置快速添加饮水的默认值 + * @param amount 饮水量(毫升) + */ +export const setQuickWaterAmount = async (amount: number): Promise => { + try { + // 确保值在合理范围内(50ml - 1000ml) + const validAmount = Math.max(50, Math.min(1000, amount)); + await AsyncStorage.setItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT, validAmount.toString()); + } catch (error) { + console.error('设置快速添加饮水默认值失败:', error); + throw error; + } +}; + +/** + * 获取快速添加饮水的默认值 + */ +export const getQuickWaterAmount = async (): Promise => { + try { + const amount = await AsyncStorage.getItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT); + return amount ? parseInt(amount, 10) : DEFAULT_PREFERENCES.quickWaterAmount; + } catch (error) { + console.error('获取快速添加饮水默认值失败:', error); + return DEFAULT_PREFERENCES.quickWaterAmount; + } +}; + +/** + * 重置所有用户偏好设置为默认值 + */ +export const resetUserPreferences = async (): Promise => { + try { + await AsyncStorage.removeItem(PREFERENCES_KEYS.QUICK_WATER_AMOUNT); + } catch (error) { + console.error('重置用户偏好设置失败:', error); + throw error; + } +}; \ No newline at end of file