From 8d71d751d64054c4e80cc10d58bfa52fb751ae19 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 5 Sep 2025 16:31:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=A5=AE=E6=B0=B4?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9D=A2=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E9=A5=AE=E6=B0=B4=E7=9B=AE=E6=A0=87=E5=92=8C?= =?UTF-8?q?=E5=BF=AB=E9=80=9F=E6=B7=BB=E5=8A=A0=E9=BB=98=E8=AE=A4=E5=80=BC?= =?UTF-8?q?=E7=9A=84=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/_layout.tsx | 12 +- app/_layout.tsx | 3 +- app/water-settings.tsx | 661 +++++++++++++++++++++++++++++++++ components/WaterIntakeCard.tsx | 238 ++++++------ 4 files changed, 781 insertions(+), 133 deletions(-) create mode 100644 app/water-settings.tsx diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 55d0745..53cdb14 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,8 +1,8 @@ +import type { BottomTabNavigationOptions } from '@react-navigation/bottom-tabs'; import * as Haptics from 'expo-haptics'; import { Tabs, usePathname } from 'expo-router'; import React from 'react'; import { Text, TouchableOpacity, View, ViewStyle } from 'react-native'; -import type { BottomTabNavigationOptions } from '@react-navigation/bottom-tabs'; import { IconSymbol } from '@/components/ui/IconSymbol'; import { Colors } from '@/constants/Colors'; @@ -18,7 +18,7 @@ type TabConfig = { const TAB_CONFIGS: Record = { statistics: { icon: 'chart.pie.fill', title: '健康' }, - explore: { icon: 'magnifyingglass.circle.fill', title: '发现' }, + // explore: { icon: 'magnifyingglass.circle.fill', title: '发现' }, goals: { icon: 'flag.fill', title: '习惯' }, personal: { icon: 'person.fill', title: '个人' }, }; @@ -35,7 +35,7 @@ export default function TabLayout() { goals: ROUTES.TAB_GOALS, statistics: ROUTES.TAB_STATISTICS, }; - + return routeMap[routeName] === pathname || pathname.includes(routeName); }; @@ -43,11 +43,11 @@ export default function TabLayout() { const createTabButton = (routeName: string) => (props: any) => { const { onPress } = props; const tabConfig = TAB_CONFIGS[routeName]; - + if (!tabConfig) return null; - + const isSelected = isTabSelected(routeName); - + const handlePress = (event: any) => { if (process.env.EXPO_OS === 'ios') { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); diff --git a/app/_layout.tsx b/app/_layout.tsx index 866a27a..fc7c1ee 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -8,11 +8,11 @@ import 'react-native-reanimated'; import PrivacyConsentModal from '@/components/PrivacyConsentModal'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { clearAiCoachSessionCache } from '@/services/aiCoachSession'; +import { backgroundTaskManager } from '@/services/backgroundTaskManager'; import { notificationService } from '@/services/notifications'; import { store } from '@/store'; import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice'; import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers'; -import { backgroundTaskManager } from '@/services/backgroundTaskManager'; import React from 'react'; import RNExitApp from 'react-native-exit-app'; @@ -140,6 +140,7 @@ export default function RootLayout() { + diff --git a/app/water-settings.tsx b/app/water-settings.tsx new file mode 100644 index 0000000..a89f76a --- /dev/null +++ b/app/water-settings.tsx @@ -0,0 +1,661 @@ +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 { Picker } from '@react-native-picker/picker'; +import { Image } from 'expo-image'; +import { router, useLocalSearchParams } from 'expo-router'; +import React, { useEffect, useState } from 'react'; +import { + Alert, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View +} from 'react-native'; +import { Swipeable } from 'react-native-gesture-handler'; + +import { HeaderBar } from '@/components/ui/HeaderBar'; +import dayjs from 'dayjs'; + +interface WaterSettingsProps { + selectedDate?: string; +} + +const WaterSettings: React.FC = () => { + const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>(); + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + + const [dailyGoal, setDailyGoal] = useState('2000'); + const [quickAddAmount, setQuickAddAmount] = useState('250'); + + // 编辑弹窗状态 + const [goalModalVisible, setGoalModalVisible] = useState(false); + const [quickAddModalVisible, setQuickAddModalVisible] = useState(false); + + // 临时选中值 + const [tempGoal, setTempGoal] = useState(parseInt(dailyGoal)); + const [tempQuickAdd, setTempQuickAdd] = useState(parseInt(quickAddAmount)); + + // 使用新的 hook 来处理指定日期的饮水数据 + const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate); + + const goalPresets = [1500, 2000, 2500, 3000, 3500, 4000]; + const quickAddPresets = [100, 150, 200, 250, 300, 350, 400, 500]; + + + // 打开饮水目标弹窗时初始化临时值 + const openGoalModal = () => { + setTempGoal(parseInt(dailyGoal)); + setGoalModalVisible(true); + }; + + // 打开快速添加弹窗时初始化临时值 + const openQuickAddModal = () => { + setTempQuickAdd(parseInt(quickAddAmount)); + setQuickAddModalVisible(true); + }; + + // 处理饮水目标确认 + const handleGoalConfirm = async () => { + setDailyGoal(tempGoal.toString()); + setGoalModalVisible(false); + + try { + const success = await updateWaterGoal(tempGoal); + if (!success) { + Alert.alert('设置失败', '无法保存饮水目标,请重试'); + } + } catch { + Alert.alert('设置失败', '无法保存饮水目标,请重试'); + } + }; + + // 处理快速添加默认值确认 + const handleQuickAddConfirm = async () => { + setQuickAddAmount(tempQuickAdd.toString()); + setQuickAddModalVisible(false); + + try { + await setQuickWaterAmount(tempQuickAdd); + } catch { + Alert.alert('设置失败', '无法保存快速添加默认值,请重试'); + } + }; + + + // 删除饮水记录 + const handleDeleteRecord = async (recordId: string) => { + 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); + } + }; + + loadUserPreferences(); + }, [dailyWaterGoal]); + + // 当dailyGoal或quickAddAmount更新时,同步更新临时状态 + useEffect(() => { + setTempGoal(parseInt(dailyGoal)); + }, [dailyGoal]); + + useEffect(() => { + setTempQuickAdd(parseInt(quickAddAmount)); + }, [quickAddAmount]); + + // 新增:饮水记录卡片组件 + 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} + )} + + + + ); + }; + + return ( + + { + // 这里会通过路由自动处理返回 + router.back(); + }} + /> + + + + {/* 第一部分:饮水配置 */} + + 饮水配置 + + {/* 设置目标部分 */} + + + 每日饮水目标 + {dailyGoal}ml + + + + + + + {/* 快速添加默认值设置部分 */} + + + 快速添加默认值 + + 设置点击右上角"+"按钮时添加的默认饮水量 + + {quickAddAmount}ml + + + + + + + + + {/* 第二部分:饮水记录 */} + + + {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 + + + + ) : ( + + + 暂无饮水记录 + 点击"添加记录"开始记录饮水量 + + )} + + + + + {/* 饮水目标编辑弹窗 */} + setGoalModalVisible(false)} + > + setGoalModalVisible(false)} /> + + + 每日饮水目标 + + setTempGoal(value)} + style={styles.picker} + > + {Array.from({ length: 96 }, (_, i) => 500 + i * 100).map(goal => ( + + ))} + + + + setGoalModalVisible(false)} + style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]} + > + 取消 + + + 确定 + + + + + + {/* 快速添加默认值编辑弹窗 */} + setQuickAddModalVisible(false)} + > + setQuickAddModalVisible(false)} /> + + + 快速添加默认值 + + setTempQuickAdd(value)} + style={styles.picker} + > + {Array.from({ length: 41 }, (_, i) => 50 + i * 10).map(amount => ( + + ))} + + + + setQuickAddModalVisible(false)} + style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]} + > + 取消 + + + 确定 + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + keyboardAvoidingView: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: 20, + }, + section: { + marginBottom: 32, + }, + sectionTitle: { + fontSize: 20, + fontWeight: '600', + marginBottom: 20, + letterSpacing: -0.5, + }, + subsectionTitle: { + fontSize: 16, + fontWeight: '500', + marginBottom: 12, + letterSpacing: -0.3, + }, + sectionSubtitle: { + fontSize: 14, + fontWeight: '400', + lineHeight: 18, + }, + input: { + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, + fontSize: 16, + fontWeight: '500', + marginBottom: 16, + }, + settingRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 16, + paddingHorizontal: 16, + borderRadius: 12, + marginBottom: 16, + }, + settingLeft: { + flex: 1, + }, + settingTitle: { + fontSize: 16, + fontWeight: '500', + marginBottom: 4, + }, + settingSubtitle: { + fontSize: 14, + marginBottom: 8, + }, + settingValue: { + fontSize: 16, + }, + settingRight: { + marginLeft: 12, + }, + quickAmountsContainer: { + marginBottom: 15, + }, + quickAmountsWrapper: { + flexDirection: 'row', + gap: 10, + paddingRight: 10, + }, + quickAmountButton: { + paddingHorizontal: 20, + paddingVertical: 8, + borderRadius: 20, + minWidth: 70, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + quickAmountText: { + fontSize: 15, + fontWeight: '500', + }, + saveButton: { + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + marginTop: 24, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 3, + }, + saveButtonText: { + fontSize: 16, + 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', + }, + modalBackdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.4)', + }, + modalSheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + padding: 16, + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + // iOS 阴影效果 + shadowColor: '#000000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + // Android 阴影效果 + elevation: 16, + }, + modalHandle: { + width: 36, + height: 4, + backgroundColor: '#E0E0E0', + borderRadius: 2, + alignSelf: 'center', + marginBottom: 20, + }, + modalTitle: { + fontSize: 20, + fontWeight: '600', + textAlign: 'center', + marginBottom: 20, + }, + pickerContainer: { + height: 200, + marginBottom: 20, + }, + picker: { + height: 200, + }, + modalActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 12, + }, + modalBtn: { + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 10, + minWidth: 80, + alignItems: 'center', + }, + modalBtnPrimary: { + // backgroundColor will be set dynamically + }, + modalBtnText: { + fontSize: 16, + fontWeight: '600', + }, + modalBtnTextPrimary: { + // color will be set dynamically + }, +}); + +export default WaterSettings; diff --git a/components/WaterIntakeCard.tsx b/components/WaterIntakeCard.tsx index 5e29586..9ce379d 100644 --- a/components/WaterIntakeCard.tsx +++ b/components/WaterIntakeCard.tsx @@ -1,9 +1,11 @@ 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 { useRouter } from 'expo-router'; import LottieView from 'lottie-react-native'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Animated, StyleSheet, @@ -12,7 +14,6 @@ import { View, ViewStyle } from 'react-native'; -import AddWaterModal from './AddWaterModal'; import { AnimatedNumber } from './AnimatedNumber'; interface WaterIntakeCardProps { @@ -24,8 +25,8 @@ const WaterIntakeCard: React.FC = ({ style, selectedDate }) => { + const router = useRouter(); const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord } = useWaterDataByDate(selectedDate); - const [isModalVisible, setIsModalVisible] = useState(false); const [quickWaterAmount, setQuickWaterAmount] = useState(150); // 默认值,将从用户偏好中加载 // 计算当前饮水量和目标 @@ -75,19 +76,21 @@ const WaterIntakeCard: React.FC = ({ 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 - } - }; + useFocusEffect( + useCallback(() => { + const loadQuickWaterAmount = async () => { + try { + const amount = await getQuickWaterAmount(); + setQuickWaterAmount(amount); + } catch (error) { + console.error('加载快速添加饮水默认值失败:', error); + // 保持默认值 250ml + } + }; - loadQuickWaterAmount(); - }, []); + loadQuickWaterAmount(); + }, []) + ); // 触发柱体动画 useEffect(() => { @@ -131,140 +134,123 @@ const WaterIntakeCard: React.FC = ({ await addWaterRecord(waterAmount, recordedAt); }; - // 处理卡片点击 - 打开配置饮水弹窗 + // 处理卡片点击 - 跳转到饮水设置页面 const handleCardPress = () => { // 触发震动反馈 if (process.env.EXPO_OS === 'ios') { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } - setIsModalVisible(true); + // 跳转到饮水设置页面,传递选中的日期参数 + router.push({ + pathname: '/water-settings', + params: selectedDate ? { selectedDate } : undefined + }); }; - // 处理关闭弹窗 - const handleCloseModal = async () => { - setIsModalVisible(false); - - // 弹窗关闭后重新加载快速添加默认值,以防用户修改了设置 - try { - const amount = await getQuickWaterAmount(); - setQuickWaterAmount(amount); - } catch (error) { - console.error('刷新快速添加默认值失败:', error); - } - }; return ( - <> - - + + - {/* 标题和加号按钮 */} - - 喝水 - {isToday && ( - - + - - )} - + {/* 标题和加号按钮 */} + + 喝水 + {isToday && ( + + + {quickWaterAmount}ml + + )} + - {/* 柱状图 */} - - - - {chartData.map((data, index) => { - // 判断是否有活动的小时 - const isActive = data.amount > 0; + {/* 柱状图 */} + + + + {chartData.map((data, index) => { + // 判断是否有活动的小时 + const isActive = data.amount > 0; - // 动画变换:高度从0到目标高度 - const animatedHeight = animatedValues[index].interpolate({ - inputRange: [0, 1], - outputRange: [0, data.height], - }); + // 动画变换:高度从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], - }); + // 动画变换:透明度从0到1(保持柱体在动画过程中可见) + const animatedOpacity = animatedValues[index].interpolate({ + inputRange: [0, 0.1, 1], + outputRange: [0, 1, 1], + }); - return ( - - {/* 背景柱体 - 始终显示,使用蓝色系的淡色 */} - + {/* 背景柱体 - 始终显示,使用蓝色系的淡色 */} + + + {/* 数据柱体 - 只有当有数据时才显示并执行动画 */} + {isActive && ( + - - {/* 数据柱体 - 只有当有数据时才显示并执行动画 */} - {isActive && ( - - )} - - ); - })} - + )} + + ); + })} + - {/* 饮水量显示 */} - - {currentIntake !== null ? ( - `${Math.round(value)}ml`} - resetToken={selectedDate} - /> - ) : ( - —— - )} - - / {targetIntake}ml - - + {/* 饮水量显示 */} + + {currentIntake !== null ? ( + `${Math.round(value)}ml`} + resetToken={selectedDate} + /> + ) : ( + —— + )} + + / {targetIntake}ml + + - - - {/* 配置饮水弹窗 */} - - + ); }; @@ -297,18 +283,18 @@ const styles = StyleSheet.create({ fontWeight: '500', }, addButton: { - width: 22, - height: 22, borderRadius: 16, backgroundColor: '#E1E7FF', alignItems: 'center', justifyContent: 'center', + paddingHorizontal: 6, + paddingVertical: 5, }, addButtonText: { - fontSize: 14, + fontSize: 10, color: '#6366F1', fontWeight: '700', - lineHeight: 14, + lineHeight: 10, }, chartContainer: { flex: 1,