From a014998848e033f1a6ae3158d8dab840a83d6245 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 26 Sep 2025 11:02:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(water):=20=E9=87=8D=E6=9E=84=E9=A5=AE?= =?UTF-8?q?=E6=B0=B4=E6=A8=A1=E5=9D=97=E5=B9=B6=E6=96=B0=E5=A2=9E=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=8F=90=E9=86=92=E8=AE=BE=E7=BD=AE=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增饮水详情页面 `/water/detail` 展示每日饮水记录与统计 - 新增饮水设置页面 `/water/settings` 支持目标与快速添加配置 - 新增喝水提醒设置页面 `/water/reminder-settings` 支持自定义时间段与间隔 - 重构 `useWaterData` Hook,支持按日期查询与实时刷新 - 新增 `WaterNotificationHelpers.scheduleCustomWaterReminders` 实现个性化提醒 - 优化心情编辑页键盘体验,新增 `KeyboardAvoidingView` 与滚动逻辑 - 升级版本号至 1.0.14 并补充路由常量 - 补充用户偏好存储字段 `waterReminderEnabled/startTime/endTime/interval` - 废弃后台定时任务中的旧版喝水提醒逻辑,改为用户手动管理 --- app.json | 2 +- app/_layout.tsx | 1 + app/mood/edit.tsx | 57 +- app/{water-settings.tsx => water/detail.tsx} | 344 +++++------ app/water/reminder-settings.tsx | 618 +++++++++++++++++++ app/water/settings.tsx | 585 ++++++++++++++++++ components/WaterIntakeCard.tsx | 24 +- constants/Routes.ts | 5 + hooks/useWaterData.ts | 18 +- ios/OutLive/Info.plist | 2 +- services/backgroundTaskManager.ts | 2 +- utils/notificationHelpers.ts | 99 ++- utils/userPreferences.ts | 181 ++++++ 13 files changed, 1732 insertions(+), 206 deletions(-) rename app/{water-settings.tsx => water/detail.tsx} (62%) create mode 100644 app/water/reminder-settings.tsx create mode 100644 app/water/settings.tsx diff --git a/app.json b/app.json index 9c1d9f7..563a72b 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Out Live", "slug": "digital-pilates", - "version": "1.0.13", + "version": "1.0.14", "orientation": "portrait", "scheme": "digitalpilates", "userInterfaceStyle": "light", diff --git a/app/_layout.tsx b/app/_layout.tsx index b15cb83..b463549 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -193,6 +193,7 @@ export default function RootLayout() { + diff --git a/app/mood/edit.tsx b/app/mood/edit.tsx index d1a1e78..ec573da 100644 --- a/app/mood/edit.tsx +++ b/app/mood/edit.tsx @@ -15,9 +15,12 @@ import { Ionicons } from '@expo/vector-icons'; import dayjs from 'dayjs'; import { LinearGradient } from 'expo-linear-gradient'; import { router, useLocalSearchParams } from 'expo-router'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Alert, Image, + Keyboard, + KeyboardAvoidingView, + Platform, ScrollView, StyleSheet, Text, @@ -43,6 +46,9 @@ export default function MoodEditScreen() { const [isDeleting, setIsDeleting] = useState(false); const [existingMood, setExistingMood] = useState(null); + const scrollViewRef = useRef(null); + const textInputRef = useRef(null); + const moodOptions = getMoodOptions(); // 从 Redux 获取数据 @@ -66,6 +72,25 @@ export default function MoodEditScreen() { } }, [moodId, moodRecords]); + // 键盘事件监听器 + useEffect(() => { + const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => { + // 键盘出现时,延迟滚动到文本输入框 + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }, 100); + }); + + const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => { + // 键盘隐藏时,可以进行必要的调整 + }); + + return () => { + keyboardDidShowListener?.remove(); + keyboardDidHideListener?.remove(); + }; + }, []); + const handleSave = async () => { if (!selectedMood) { Alert.alert('提示', '请选择心情'); @@ -163,7 +188,18 @@ export default function MoodEditScreen() { tone="light" /> - + + {/* 日期显示 */} @@ -211,6 +247,7 @@ export default function MoodEditScreen() { 心情日记 记录你的心情,珍藏美好回忆 { + // 当文本输入框获得焦点时,滚动到输入框 + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }, 300); + }} /> {description.length}/1000 - + + {/* 底部按钮 */} @@ -294,10 +338,15 @@ const styles = StyleSheet.create({ safeArea: { flex: 1, }, - + keyboardAvoidingView: { + flex: 1, + }, content: { flex: 1, }, + scrollContent: { + paddingBottom: 100, // 为底部按钮留出空间 + }, dateSection: { backgroundColor: 'rgba(255,255,255,0.95)', margin: 12, diff --git a/app/water-settings.tsx b/app/water/detail.tsx similarity index 62% rename from app/water-settings.tsx rename to app/water/detail.tsx index f46afd6..47ce8cf 100644 --- a/app/water-settings.tsx +++ b/app/water/detail.tsx @@ -1,7 +1,8 @@ import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; import { useWaterDataByDate } from '@/hooks/useWaterData'; -import { getQuickWaterAmount, setQuickWaterAmount } from '@/utils/userPreferences'; +import { WaterNotificationHelpers } from '@/utils/notificationHelpers'; +import { getQuickWaterAmount, getWaterReminderSettings, setWaterReminderSettings as saveWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences'; import { Ionicons } from '@expo/vector-icons'; import { Picker } from '@react-native-picker/picker'; import { Image } from 'expo-image'; @@ -16,6 +17,7 @@ import { Pressable, ScrollView, StyleSheet, + Switch, Text, TouchableOpacity, View @@ -25,11 +27,11 @@ import { Swipeable } from 'react-native-gesture-handler'; import { HeaderBar } from '@/components/ui/HeaderBar'; import dayjs from 'dayjs'; -interface WaterSettingsProps { +interface WaterDetailProps { selectedDate?: string; } -const WaterSettings: React.FC = () => { +const WaterDetail: React.FC = () => { const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>(); const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; @@ -37,14 +39,7 @@ const WaterSettings: React.FC = () => { const [dailyGoal, setDailyGoal] = useState('2000'); const [quickAddAmount, setQuickAddAmount] = useState('250'); - // 设置弹窗状态 - const [settingsModalVisible, setSettingsModalVisible] = useState(false); - const [goalModalVisible, setGoalModalVisible] = useState(false); - const [quickAddModalVisible, setQuickAddModalVisible] = useState(false); - - // 临时选中值 - const [tempGoal, setTempGoal] = useState(parseInt(dailyGoal)); - const [tempQuickAdd, setTempQuickAdd] = useState(parseInt(quickAddAmount)); + // Remove modal states as they are now in separate settings page // 使用新的 hook 来处理指定日期的饮水数据 const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate); @@ -52,51 +47,12 @@ const WaterSettings: React.FC = () => { - // 处理设置按钮点击 + // 处理设置按钮点击 - 跳转到设置页面 const handleSettingsPress = () => { - setSettingsModalVisible(true); + router.push('/water/settings'); }; - // 打开饮水目标弹窗时初始化临时值 - const openGoalModal = () => { - setTempGoal(parseInt(dailyGoal)); - setSettingsModalVisible(false); - setGoalModalVisible(true); - }; - - // 打开快速添加弹窗时初始化临时值 - const openQuickAddModal = () => { - setTempQuickAdd(parseInt(quickAddAmount)); - setSettingsModalVisible(false); - 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('设置失败', '无法保存快速添加默认值,请重试'); - } - }; + // Remove all modal-related functions as they are now in separate settings page // 删除饮水记录 @@ -123,15 +79,6 @@ const WaterSettings: React.FC = () => { 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); @@ -225,7 +172,7 @@ const WaterSettings: React.FC = () => { { // 这里会通过路由自动处理返回 router.back(); @@ -288,129 +235,7 @@ const WaterSettings: React.FC = () => { - {/* 设置主弹窗 */} - setSettingsModalVisible(false)} - > - setSettingsModalVisible(false)} /> - - - 饮水设置 - - {/* 菜单项 */} - - - - - - - - 每日饮水目标 - {dailyWaterGoal || dailyGoal}ml - - - - - - - - - - - - 快速添加默认值 - - 设置点击"+"按钮时添加的默认饮水量 - - {quickAddAmount}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 }]} - > - 取消 - - - 确定 - - - - + {/* All modals have been moved to the separate water-settings page */} ); }; @@ -724,6 +549,151 @@ const styles = StyleSheet.create({ settingsMenuItemValue: { fontSize: 14, }, + // 喝水提醒配置弹窗样式 + waterReminderModalSheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + padding: 16, + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + maxHeight: '80%', + shadowColor: '#000000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 16, + }, + waterReminderContent: { + flex: 1, + marginBottom: 20, + }, + waterReminderSection: { + marginBottom: 24, + }, + waterReminderSectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 8, + }, + waterReminderSectionTitleContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + waterReminderSectionTitle: { + fontSize: 16, + fontWeight: '600', + }, + waterReminderSectionDesc: { + fontSize: 14, + lineHeight: 20, + marginTop: 4, + }, + timeRangeContainer: { + flexDirection: 'row', + gap: 16, + marginTop: 16, + }, + timePickerContainer: { + flex: 1, + }, + timeLabel: { + fontSize: 14, + fontWeight: '500', + marginBottom: 8, + }, + timePicker: { + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + timePickerText: { + fontSize: 16, + fontWeight: '500', + }, + timePickerIcon: { + opacity: 0.6, + }, + intervalContainer: { + marginTop: 16, + }, + intervalPickerContainer: { + backgroundColor: '#F8F9FA', + borderRadius: 8, + overflow: 'hidden', + }, + intervalPicker: { + height: 120, + }, + // 时间选择器弹窗样式 + timePickerModalSheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + padding: 16, + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + maxHeight: '60%', + shadowColor: '#000000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 16, + }, + timePickerContent: { + flex: 1, + marginBottom: 20, + }, + timePickerSection: { + marginBottom: 20, + }, + timePickerLabel: { + fontSize: 16, + fontWeight: '600', + marginBottom: 12, + textAlign: 'center', + }, + hourPickerContainer: { + backgroundColor: '#F8F9FA', + borderRadius: 8, + overflow: 'hidden', + }, + hourPicker: { + height: 160, + }, + timeRangePreview: { + backgroundColor: '#F0F8FF', + borderRadius: 8, + padding: 16, + marginTop: 16, + alignItems: 'center', + }, + timeRangePreviewLabel: { + fontSize: 12, + fontWeight: '500', + marginBottom: 4, + }, + timeRangePreviewText: { + fontSize: 18, + fontWeight: '600', + marginBottom: 8, + }, + timeRangeWarning: { + fontSize: 12, + color: '#FF6B6B', + textAlign: 'center', + lineHeight: 18, + }, }); -export default WaterSettings; +export default WaterDetail; diff --git a/app/water/reminder-settings.tsx b/app/water/reminder-settings.tsx new file mode 100644 index 0000000..098f47a --- /dev/null +++ b/app/water/reminder-settings.tsx @@ -0,0 +1,618 @@ +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { WaterNotificationHelpers } from '@/utils/notificationHelpers'; +import { getWaterReminderSettings, setWaterReminderSettings as saveWaterReminderSettings } from '@/utils/userPreferences'; +import { Ionicons } from '@expo/vector-icons'; +import { Picker } from '@react-native-picker/picker'; +import { LinearGradient } from 'expo-linear-gradient'; +import { router } from 'expo-router'; +import React, { useEffect, useState } from 'react'; +import { + Alert, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + Switch, + Text, + TouchableOpacity, + View +} from 'react-native'; + +import { HeaderBar } from '@/components/ui/HeaderBar'; + +const WaterReminderSettings: React.FC = () => { + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + + const [startTimePickerVisible, setStartTimePickerVisible] = useState(false); + const [endTimePickerVisible, setEndTimePickerVisible] = useState(false); + + // 喝水提醒相关状态 + const [waterReminderSettings, setWaterReminderSettings] = useState({ + enabled: false, + startTime: '08:00', + endTime: '22:00', + interval: 60, + }); + + // 时间选择器临时值 + const [tempStartHour, setTempStartHour] = useState(8); + const [tempEndHour, setTempEndHour] = useState(22); + + // 打开开始时间选择器 + const openStartTimePicker = () => { + const currentHour = parseInt(waterReminderSettings.startTime.split(':')[0]); + setTempStartHour(currentHour); + setStartTimePickerVisible(true); + }; + + // 打开结束时间选择器 + const openEndTimePicker = () => { + const currentHour = parseInt(waterReminderSettings.endTime.split(':')[0]); + setTempEndHour(currentHour); + setEndTimePickerVisible(true); + }; + + // 确认开始时间选择 + const confirmStartTime = () => { + const newStartTime = `${String(tempStartHour).padStart(2, '0')}:00`; + + // 检查时间合理性 + if (isValidTimeRange(newStartTime, waterReminderSettings.endTime)) { + setWaterReminderSettings(prev => ({ + ...prev, + startTime: newStartTime + })); + setStartTimePickerVisible(false); + } else { + Alert.alert( + '时间设置提示', + '开始时间不能晚于或等于结束时间,请重新选择', + [{ text: '确定' }] + ); + } + }; + + // 确认结束时间选择 + const confirmEndTime = () => { + const newEndTime = `${String(tempEndHour).padStart(2, '0')}:00`; + + // 检查时间合理性 + if (isValidTimeRange(waterReminderSettings.startTime, newEndTime)) { + setWaterReminderSettings(prev => ({ + ...prev, + endTime: newEndTime + })); + setEndTimePickerVisible(false); + } else { + Alert.alert( + '时间设置提示', + '结束时间不能早于或等于开始时间,请重新选择', + [{ text: '确定' }] + ); + } + }; + + // 验证时间范围是否有效 + const isValidTimeRange = (startTime: string, endTime: string): boolean => { + const [startHour] = startTime.split(':').map(Number); + const [endHour] = endTime.split(':').map(Number); + + // 支持跨天的情况,如果结束时间小于开始时间,认为是跨天有效的 + if (endHour < startHour) { + return true; // 跨天情况,如 22:00 到 08:00 + } + + // 同一天内,结束时间必须大于开始时间 + return endHour > startHour; + }; + + // 处理喝水提醒配置保存 + const handleWaterReminderSave = async () => { + try { + // 保存设置到本地存储 + await saveWaterReminderSettings(waterReminderSettings); + + // 设置或取消通知 + // 这里使用 "用户" 作为默认用户名,实际项目中应该从用户状态获取 + const userName = '用户'; + await WaterNotificationHelpers.scheduleCustomWaterReminders(userName, waterReminderSettings); + + if (waterReminderSettings.enabled) { + const timeInfo = `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}`; + const intervalInfo = `每${waterReminderSettings.interval}分钟`; + Alert.alert( + '设置成功', + `喝水提醒已开启\n\n时间段:${timeInfo}\n提醒间隔:${intervalInfo}\n\n我们将在指定时间段内定期提醒您喝水`, + [{ text: '确定', onPress: () => router.back() }] + ); + } else { + Alert.alert('设置成功', '喝水提醒已关闭', [{ text: '确定', onPress: () => router.back() }]); + } + } catch (error) { + console.error('保存喝水提醒设置失败:', error); + Alert.alert('保存失败', '无法保存喝水提醒设置,请重试'); + } + }; + + // 加载用户偏好设置 + useEffect(() => { + const loadUserPreferences = async () => { + try { + // 加载喝水提醒设置 + const reminderSettings = await getWaterReminderSettings(); + setWaterReminderSettings(reminderSettings); + + // 初始化时间选择器临时值 + const startHour = parseInt(reminderSettings.startTime.split(':')[0]); + const endHour = parseInt(reminderSettings.endTime.split(':')[0]); + setTempStartHour(startHour); + setTempEndHour(endHour); + } catch (error) { + console.error('加载用户偏好设置失败:', error); + } + }; + + loadUserPreferences(); + }, []); + + return ( + + {/* 背景渐变 */} + + + {/* 装饰性圆圈 */} + + + + { + router.back(); + }} + /> + + + + {/* 开启/关闭提醒 */} + + + + + 推送提醒 + + setWaterReminderSettings(prev => ({ ...prev, enabled }))} + trackColor={{ false: '#E5E5E5', true: '#3498DB' }} + thumbColor={waterReminderSettings.enabled ? '#FFFFFF' : '#FFFFFF'} + /> + + + 开启后将在指定时间段内定期推送喝水提醒 + + + + {/* 时间段设置 */} + {waterReminderSettings.enabled && ( + <> + + 提醒时间段 + + 只在指定时间段内发送提醒,避免打扰您的休息 + + + + {/* 开始时间 */} + + 开始时间 + + {waterReminderSettings.startTime} + + + + + {/* 结束时间 */} + + 结束时间 + + {waterReminderSettings.endTime} + + + + + + + {/* 提醒间隔设置 */} + + 提醒间隔 + + 选择提醒的频率,建议30-120分钟为宜 + + + + + setWaterReminderSettings(prev => ({ ...prev, interval }))} + style={styles.intervalPicker} + > + {[30, 45, 60, 90, 120, 150, 180].map(interval => ( + + ))} + + + + + + )} + + {/* 保存按钮 */} + + + 保存设置 + + + + + + {/* 开始时间选择器弹窗 */} + setStartTimePickerVisible(false)} + > + setStartTimePickerVisible(false)} /> + + + 选择开始时间 + + + + 小时 + + setTempStartHour(hour)} + style={styles.hourPicker} + > + {Array.from({ length: 24 }, (_, i) => ( + + ))} + + + + + + 时间段预览 + + {String(tempStartHour).padStart(2, '0')}:00 - {waterReminderSettings.endTime} + + {!isValidTimeRange(`${String(tempStartHour).padStart(2, '0')}:00`, waterReminderSettings.endTime) && ( + ⚠️ 开始时间不能晚于或等于结束时间 + )} + + + + + setStartTimePickerVisible(false)} + style={[styles.modalBtn, { backgroundColor: 'white' }]} + > + 取消 + + + 确定 + + + + + + {/* 结束时间选择器弹窗 */} + setEndTimePickerVisible(false)} + > + setEndTimePickerVisible(false)} /> + + + 选择结束时间 + + + + 小时 + + setTempEndHour(hour)} + style={styles.hourPicker} + > + {Array.from({ length: 24 }, (_, i) => ( + + ))} + + + + + + 时间段预览 + + {waterReminderSettings.startTime} - {String(tempEndHour).padStart(2, '0')}:00 + + {!isValidTimeRange(waterReminderSettings.startTime, `${String(tempEndHour).padStart(2, '0')}:00`) && ( + ⚠️ 结束时间不能早于或等于开始时间 + )} + + + + + setEndTimePickerVisible(false)} + style={[styles.modalBtn, { backgroundColor: 'white' }]} + > + 取消 + + + 确定 + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + gradientBackground: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + decorativeCircle1: { + position: 'absolute', + top: 40, + right: 20, + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#0EA5E9', + opacity: 0.1, + }, + decorativeCircle2: { + position: 'absolute', + bottom: -15, + left: -15, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#0EA5E9', + opacity: 0.05, + }, + keyboardAvoidingView: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: 20, + }, + waterReminderSection: { + marginBottom: 32, + }, + waterReminderSectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 8, + }, + waterReminderSectionTitleContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + waterReminderSectionTitle: { + fontSize: 18, + fontWeight: '600', + }, + waterReminderSectionDesc: { + fontSize: 14, + lineHeight: 20, + marginTop: 4, + }, + timeRangeContainer: { + flexDirection: 'row', + gap: 16, + marginTop: 16, + }, + timePickerContainer: { + flex: 1, + }, + timeLabel: { + fontSize: 14, + fontWeight: '500', + marginBottom: 8, + }, + timePicker: { + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + timePickerText: { + fontSize: 16, + fontWeight: '500', + }, + timePickerIcon: { + opacity: 0.6, + }, + intervalContainer: { + marginTop: 16, + }, + intervalPickerContainer: { + backgroundColor: 'white', + borderRadius: 8, + overflow: 'hidden', + }, + intervalPicker: { + height: 200, + }, + saveButtonContainer: { + marginTop: 20, + marginBottom: 40, + }, + saveButton: { + paddingVertical: 16, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, + saveButtonText: { + fontSize: 16, + fontWeight: '600', + }, + modalBackdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.4)', + }, + timePickerModalSheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + padding: 16, + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + maxHeight: '60%', + shadowColor: '#000000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 16, + }, + modalHandle: { + width: 36, + height: 4, + backgroundColor: '#E0E0E0', + borderRadius: 2, + alignSelf: 'center', + marginBottom: 20, + }, + modalTitle: { + fontSize: 20, + fontWeight: '600', + textAlign: 'center', + marginBottom: 20, + }, + timePickerContent: { + flex: 1, + marginBottom: 20, + }, + timePickerSection: { + marginBottom: 20, + }, + timePickerLabel: { + fontSize: 16, + fontWeight: '600', + marginBottom: 12, + textAlign: 'center', + }, + hourPickerContainer: { + backgroundColor: '#F8F9FA', + borderRadius: 8, + overflow: 'hidden', + }, + hourPicker: { + height: 160, + }, + timeRangePreview: { + backgroundColor: '#F0F8FF', + borderRadius: 8, + padding: 16, + marginTop: 16, + alignItems: 'center', + }, + timeRangePreviewLabel: { + fontSize: 12, + fontWeight: '500', + marginBottom: 4, + }, + timeRangePreviewText: { + fontSize: 18, + fontWeight: '600', + marginBottom: 8, + }, + timeRangeWarning: { + fontSize: 12, + color: '#FF6B6B', + textAlign: 'center', + lineHeight: 18, + }, + 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 WaterReminderSettings; \ No newline at end of file diff --git a/app/water/settings.tsx b/app/water/settings.tsx new file mode 100644 index 0000000..ebaa889 --- /dev/null +++ b/app/water/settings.tsx @@ -0,0 +1,585 @@ +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { getQuickWaterAmount, getWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences'; +import { Ionicons } from '@expo/vector-icons'; +import { Picker } from '@react-native-picker/picker'; +import { LinearGradient } from 'expo-linear-gradient'; +import { router } from 'expo-router'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; +import { + Alert, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + Switch, + Text, + TouchableOpacity, + View +} from 'react-native'; + +import { HeaderBar } from '@/components/ui/HeaderBar'; + +const WaterSettings: React.FC = () => { + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colorTokens = Colors[theme]; + + const [quickAddAmount, setQuickAddAmount] = useState('250'); + + // 喝水提醒设置状态(用于显示当前设置) + const [waterReminderSettings, setWaterReminderSettings] = useState({ + enabled: false, + startTime: '08:00', + endTime: '22:00', + interval: 60, + }); + + // 弹窗状态 + const [goalModalVisible, setGoalModalVisible] = useState(false); + const [quickAddModalVisible, setQuickAddModalVisible] = useState(false); + + // 临时选中值 + const [tempGoal, setTempGoal] = useState(2000); + const [tempQuickAdd, setTempQuickAdd] = useState(250); + + + // 当前饮水目标(从本地存储获取) + const [currentWaterGoal, setCurrentWaterGoal] = useState(2000); + + // 打开饮水目标弹窗时初始化临时值 + const openGoalModal = () => { + setTempGoal(currentWaterGoal); + setGoalModalVisible(true); + }; + + // 打开快速添加弹窗时初始化临时值 + const openQuickAddModal = () => { + setTempQuickAdd(parseInt(quickAddAmount)); + setQuickAddModalVisible(true); + }; + + // 打开喝水提醒页面 + const openWaterReminderSettings = () => { + router.push('/water/reminder-settings'); + }; + + + // 处理饮水目标确认 + const handleGoalConfirm = async () => { + setCurrentWaterGoal(tempGoal); + setGoalModalVisible(false); + + // 这里可以添加保存到本地存储或发送到后端的逻辑 + Alert.alert('设置成功', `每日饮水目标已设置为 ${tempGoal}ml`); + }; + + // 处理快速添加默认值确认 + const handleQuickAddConfirm = async () => { + setQuickAddAmount(tempQuickAdd.toString()); + setQuickAddModalVisible(false); + + try { + await setQuickWaterAmount(tempQuickAdd); + Alert.alert('设置成功', `快速添加默认值已设置为 ${tempQuickAdd}ml`); + } catch { + Alert.alert('设置失败', '无法保存快速添加默认值,请重试'); + } + }; + + // 加载用户偏好设置 + const loadUserPreferences = useCallback(async () => { + try { + const amount = await getQuickWaterAmount(); + setQuickAddAmount(amount.toString()); + setTempQuickAdd(amount); + + // 加载喝水提醒设置来显示当前设置状态 + const reminderSettings = await getWaterReminderSettings(); + setWaterReminderSettings(reminderSettings); + } catch (error) { + console.error('加载用户偏好设置失败:', error); + } + }, []); + + // 初始化加载 + useEffect(() => { + loadUserPreferences(); + }, [loadUserPreferences]); + + // 页面聚焦时重新加载设置(从提醒设置页面返回时) + useFocusEffect( + useCallback(() => { + loadUserPreferences(); + }, [loadUserPreferences]) + ); + + return ( + + {/* 背景渐变 */} + + + {/* 装饰性圆圈 */} + + + + { + router.back(); + }} + /> + + + + {/* 设置列表 */} + + + + + + + + + 每日饮水目标 + {currentWaterGoal}ml + + + + + + + + + + + + 快速添加默认值 + + 设置点击"+"按钮时添加的默认饮水量 + + {quickAddAmount}ml + + + + + + + + + + + + 喝水提醒 + + 设置定时提醒您补充水分 + + + {waterReminderSettings.enabled ? `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}, 每${waterReminderSettings.interval}分钟` : '已关闭'} + + + + + + + + + + + {/* 饮水目标编辑弹窗 */} + 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, + }, + gradientBackground: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + decorativeCircle1: { + position: 'absolute', + top: 40, + right: 20, + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#0EA5E9', + opacity: 0.1, + }, + decorativeCircle2: { + position: 'absolute', + bottom: -15, + left: -15, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#0EA5E9', + opacity: 0.05, + }, + keyboardAvoidingView: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: 20, + }, + section: { + marginBottom: 32, + }, + settingsMenuContainer: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 2, + overflow: 'hidden', + }, + settingsMenuItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 16, + paddingHorizontal: 16, + borderBottomWidth: 1, + borderBottomColor: '#F1F3F4', + }, + settingsMenuItemLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + settingsIconContainer: { + width: 32, + height: 32, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + settingsMenuItemContent: { + flex: 1, + }, + settingsMenuItemTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 4, + }, + settingsMenuItemSubtitle: { + fontSize: 13, + marginBottom: 6, + }, + settingsMenuItemValue: { + fontSize: 14, + fontWeight: '500', + }, + 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, + shadowColor: '#000000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + 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 + }, + // 喝水提醒配置弹窗样式 + waterReminderModalSheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + padding: 16, + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + maxHeight: '80%', + shadowColor: '#000000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 16, + }, + waterReminderContent: { + flex: 1, + marginBottom: 20, + }, + waterReminderSection: { + marginBottom: 24, + }, + waterReminderSectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 8, + }, + waterReminderSectionTitleContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + waterReminderSectionTitle: { + fontSize: 16, + fontWeight: '600', + }, + waterReminderSectionDesc: { + fontSize: 14, + lineHeight: 20, + marginTop: 4, + }, + timeRangeContainer: { + flexDirection: 'row', + gap: 16, + marginTop: 16, + }, + timePickerContainer: { + flex: 1, + }, + timeLabel: { + fontSize: 14, + fontWeight: '500', + marginBottom: 8, + }, + timePicker: { + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + timePickerText: { + fontSize: 16, + fontWeight: '500', + }, + timePickerIcon: { + opacity: 0.6, + }, + intervalContainer: { + marginTop: 16, + }, + intervalPickerContainer: { + backgroundColor: '#F8F9FA', + borderRadius: 8, + overflow: 'hidden', + }, + intervalPicker: { + height: 120, + }, + // 时间选择器弹窗样式 + timePickerModalSheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + padding: 16, + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + maxHeight: '60%', + shadowColor: '#000000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 16, + }, + timePickerContent: { + flex: 1, + marginBottom: 20, + }, + timePickerSection: { + marginBottom: 20, + }, + timePickerLabel: { + fontSize: 16, + fontWeight: '600', + marginBottom: 12, + textAlign: 'center', + }, + hourPickerContainer: { + backgroundColor: '#F8F9FA', + borderRadius: 8, + overflow: 'hidden', + }, + hourPicker: { + height: 160, + }, + timeRangePreview: { + backgroundColor: '#F0F8FF', + borderRadius: 8, + padding: 16, + marginTop: 16, + alignItems: 'center', + }, + timeRangePreviewLabel: { + fontSize: 12, + fontWeight: '500', + marginBottom: 4, + }, + timeRangePreviewText: { + fontSize: 18, + fontWeight: '600', + marginBottom: 8, + }, + timeRangeWarning: { + fontSize: 12, + color: '#FF6B6B', + textAlign: 'center', + lineHeight: 18, + }, +}); + +export default WaterSettings; \ No newline at end of file diff --git a/components/WaterIntakeCard.tsx b/components/WaterIntakeCard.tsx index 22b969d..7a4d320 100644 --- a/components/WaterIntakeCard.tsx +++ b/components/WaterIntakeCard.tsx @@ -27,7 +27,7 @@ const WaterIntakeCard: React.FC = ({ selectedDate }) => { const router = useRouter(); - const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord } = useWaterDataByDate(selectedDate); + const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord, getWaterRecordsByDate } = useWaterDataByDate(selectedDate); const [quickWaterAmount, setQuickWaterAmount] = useState(150); // 默认值,将从用户偏好中加载 // 计算当前饮水量和目标 @@ -76,21 +76,25 @@ const WaterIntakeCard: React.FC = ({ // 判断是否是今天 const isToday = selectedDate === dayjs().format('YYYY-MM-DD') || !selectedDate; - // 加载用户偏好的快速添加饮水默认值 + // 页面聚焦时重新加载数据 useFocusEffect( useCallback(() => { - const loadQuickWaterAmount = async () => { + 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); - // 保持默认值 250ml + console.error('页面聚焦时加载数据失败:', error); } }; - loadQuickWaterAmount(); - }, []) + loadDataOnFocus(); + }, [selectedDate, getWaterRecordsByDate]) ); // 触发柱体动画 @@ -135,16 +139,16 @@ const WaterIntakeCard: React.FC = ({ await addWaterRecord(waterAmount, recordedAt); }; - // 处理卡片点击 - 跳转到饮水设置页面 + // 处理卡片点击 - 跳转到饮水详情页面 const handleCardPress = async () => { // 触发震动反馈 if (process.env.EXPO_OS === 'ios') { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } - // 跳转到饮水设置页面,传递选中的日期参数 + // 跳转到饮水详情页面,传递选中的日期参数 router.push({ - pathname: '/water-settings', + pathname: '/water/detail', params: selectedDate ? { selectedDate } : undefined }); }; diff --git a/constants/Routes.ts b/constants/Routes.ts index 7610d17..c4b608b 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -47,6 +47,11 @@ export const ROUTES = { SLEEP_DETAIL: '/sleep-detail', BASAL_METABOLISM_DETAIL: '/basal-metabolism-detail', + // 饮水相关路由 + WATER_DETAIL: '/water/detail', + WATER_SETTINGS: '/water/settings', + WATER_REMINDER_SETTINGS: '/water/reminder-settings', + // 任务相关路由 TASK_DETAIL: '/task-detail', diff --git a/hooks/useWaterData.ts b/hooks/useWaterData.ts index 8077ec3..db753b1 100644 --- a/hooks/useWaterData.ts +++ b/hooks/useWaterData.ts @@ -1,4 +1,5 @@ import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health'; +import { logger } from '@/utils/logger'; import { Toast } from '@/utils/toast.utils'; import { getQuickWaterAmount, getWaterGoalFromStorage, setWaterGoalToStorage } from '@/utils/userPreferences'; import { refreshWidget, syncWaterDataToWidget } from '@/utils/widgetDataSync'; @@ -495,18 +496,25 @@ export const useWaterDataByDate = (targetDate?: string) => { setError(null); try { + logger.debug('🚰 开始获取饮水记录,日期:', date); const options = createDateRange(date); + logger.debug('🚰 查询选项:', options); + const healthKitRecords = await getWaterIntakeFromHealthKit(options); + logger.debug('🚰 从HealthKit获取到的原始数据:', healthKitRecords); // 转换数据格式并按时间排序 const convertedRecords = healthKitRecords .map(convertHealthKitToWaterRecord) .sort((a, b) => new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime()); + logger.debug('🚰 转换后的记录:', convertedRecords); + logger.debug('🚰 记录数量:', convertedRecords.length); + setWaterRecords(convertedRecords); return convertedRecords; } catch (error) { - console.error('获取饮水记录失败:', error); + console.error('🚰 获取饮水记录失败:', error); setError('获取饮水记录失败'); Toast.error('获取饮水记录失败'); return []; @@ -638,7 +646,11 @@ export const useWaterDataByDate = (targetDate?: string) => { // 计算指定日期的统计数据 const waterStats = useMemo(() => { + logger.debug('🚰 计算waterStats - waterRecords:', waterRecords); + logger.debug('🚰 计算waterStats - dailyWaterGoal:', dailyWaterGoal); + if (!waterRecords || waterRecords.length === 0) { + logger.debug('🚰 没有饮水记录,返回默认值'); return { totalAmount: 0, completionRate: 0, @@ -649,6 +661,10 @@ export const useWaterDataByDate = (targetDate?: string) => { const totalAmount = waterRecords.reduce((total, record) => total + record.amount, 0); const completionRate = dailyWaterGoal > 0 ? Math.min((totalAmount / dailyWaterGoal) * 100, 100) : 0; + logger.debug('🚰 计算结果 - totalAmount:', totalAmount); + logger.debug('🚰 计算结果 - completionRate:', completionRate); + logger.debug('🚰 计算结果 - recordCount:', waterRecords.length); + return { totalAmount, completionRate, diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index 2f26a49..656bebe 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -25,7 +25,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.13 + 1.0.14 CFBundleSignature ???? CFBundleURLTypes diff --git a/services/backgroundTaskManager.ts b/services/backgroundTaskManager.ts index 7b25fb7..9bad20c 100644 --- a/services/backgroundTaskManager.ts +++ b/services/backgroundTaskManager.ts @@ -143,7 +143,7 @@ async function executeBackgroundTasks(): Promise { // await sendTestNotification() - // 执行喝水提醒检查任务 + // 执行喝水提醒检查任务 - 已禁用,改为由用户手动在设置页面管理 await executeWaterReminderTask(); // 执行站立提醒检查任务 diff --git a/utils/notificationHelpers.ts b/utils/notificationHelpers.ts index 44c4976..626d8e1 100644 --- a/utils/notificationHelpers.ts +++ b/utils/notificationHelpers.ts @@ -822,6 +822,102 @@ export class WaterNotificationHelpers { } } + /** + * 根据用户设置安排喝水提醒通知 + * @param userName 用户名 + * @param settings 喝水提醒设置 + * @returns 通知ID数组 + */ + static async scheduleCustomWaterReminders( + userName: string, + settings: { + enabled: boolean; + startTime: string; // 格式: "HH:mm" + endTime: string; // 格式: "HH:mm" + interval: number; // 分钟 + } + ): Promise { + try { + const notificationIds: string[] = []; + + // 如果不启用提醒,先取消所有提醒 + if (!settings.enabled) { + await this.cancelAllWaterReminders(); + return notificationIds; + } + + // 先取消现有的喝水提醒 + await this.cancelAllWaterReminders(); + + // 解析开始和结束时间 + const [startHour, startMinute] = settings.startTime.split(':').map(Number); + const [endHour, endMinute] = settings.endTime.split(':').map(Number); + + // 计算一天内所有的提醒时间点 + const reminderTimes: { hour: number; minute: number }[] = []; + + // 创建开始时间的 Date 对象 + let currentTime = new Date(); + currentTime.setHours(startHour, startMinute, 0, 0); + + // 创建结束时间的 Date 对象 + let endTime = new Date(); + endTime.setHours(endHour, endMinute, 0, 0); + + // 如果结束时间小于开始时间,说明跨天了,结束时间设为第二天 + if (endTime <= currentTime) { + endTime.setDate(endTime.getDate() + 1); + } + + // 生成所有的提醒时间点 + while (currentTime < endTime) { + reminderTimes.push({ + hour: currentTime.getHours(), + minute: currentTime.getMinutes(), + }); + + // 增加间隔时间 + currentTime.setTime(currentTime.getTime() + settings.interval * 60 * 1000); + } + + console.log(`计算出${reminderTimes.length}个喝水提醒时间点:`, reminderTimes); + + // 为每个时间点创建重复通知 + for (const time of reminderTimes) { + const notificationId = await notificationService.scheduleCalendarRepeatingNotification( + { + title: '💧 喝水时间到啦!', + body: `${userName},记得补充水分哦~保持身体健康!`, + data: { + type: 'custom_water_reminder', + isCustomReminder: true, + reminderHour: time.hour, + reminderMinute: time.minute, + url: '/statistics' + }, + sound: true, + priority: 'normal', + }, + { + type: Notifications.SchedulableTriggerInputTypes.DAILY, + hour: time.hour, + minute: time.minute, + } + ); + + notificationIds.push(notificationId); + console.log(`已安排${time.hour}:${String(time.minute).padStart(2, '0')}的喝水提醒,通知ID: ${notificationId}`); + } + + console.log(`自定义喝水提醒设置完成,共${notificationIds.length}个通知`); + return notificationIds; + + } catch (error) { + console.error('设置自定义喝水提醒失败:', error); + throw error; + } + } + /** * 取消所有喝水提醒 */ @@ -831,7 +927,8 @@ export class WaterNotificationHelpers { for (const notification of notifications) { if (notification.content.data?.type === 'water_reminder' || - notification.content.data?.type === 'regular_water_reminder') { + notification.content.data?.type === 'regular_water_reminder' || + notification.content.data?.type === 'custom_water_reminder') { await notificationService.cancelNotification(notification.identifier); console.log('已取消喝水提醒:', notification.identifier); } diff --git a/utils/userPreferences.ts b/utils/userPreferences.ts index 504930f..b107229 100644 --- a/utils/userPreferences.ts +++ b/utils/userPreferences.ts @@ -7,6 +7,10 @@ const PREFERENCES_KEYS = { NOTIFICATION_ENABLED: 'user_preference_notification_enabled', FITNESS_EXERCISE_MINUTES_INFO_DISMISSED: 'user_preference_fitness_exercise_minutes_info_dismissed', FITNESS_ACTIVE_HOURS_INFO_DISMISSED: 'user_preference_fitness_active_hours_info_dismissed', + WATER_REMINDER_ENABLED: 'user_preference_water_reminder_enabled', + WATER_REMINDER_START_TIME: 'user_preference_water_reminder_start_time', + WATER_REMINDER_END_TIME: 'user_preference_water_reminder_end_time', + WATER_REMINDER_INTERVAL: 'user_preference_water_reminder_interval', } as const; // 用户偏好设置接口 @@ -16,6 +20,10 @@ export interface UserPreferences { notificationEnabled: boolean; fitnessExerciseMinutesInfoDismissed: boolean; fitnessActiveHoursInfoDismissed: boolean; + waterReminderEnabled: boolean; + waterReminderStartTime: string; // 格式: "08:00" + waterReminderEndTime: string; // 格式: "22:00" + waterReminderInterval: number; // 分钟 } // 默认的用户偏好设置 @@ -25,6 +33,10 @@ const DEFAULT_PREFERENCES: UserPreferences = { notificationEnabled: true, // 默认开启消息推送 fitnessExerciseMinutesInfoDismissed: false, // 默认显示锻炼分钟说明 fitnessActiveHoursInfoDismissed: false, // 默认显示活动小时说明 + waterReminderEnabled: true, // 默认关闭喝水提醒 + waterReminderStartTime: '08:00', // 默认开始时间早上8点 + waterReminderEndTime: '22:00', // 默认结束时间晚上10点 + waterReminderInterval: 60, // 默认提醒间隔60分钟 }; /** @@ -37,6 +49,10 @@ export const getUserPreferences = async (): Promise => { const notificationEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED); const fitnessExerciseMinutesInfoDismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_EXERCISE_MINUTES_INFO_DISMISSED); const fitnessActiveHoursInfoDismissed = await AsyncStorage.getItem(PREFERENCES_KEYS.FITNESS_ACTIVE_HOURS_INFO_DISMISSED); + const waterReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_ENABLED); + const waterReminderStartTime = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_START_TIME); + const waterReminderEndTime = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_END_TIME); + const waterReminderInterval = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_INTERVAL); return { quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount, @@ -44,6 +60,10 @@ export const getUserPreferences = async (): Promise => { notificationEnabled: notificationEnabled !== null ? notificationEnabled === 'true' : DEFAULT_PREFERENCES.notificationEnabled, fitnessExerciseMinutesInfoDismissed: fitnessExerciseMinutesInfoDismissed !== null ? fitnessExerciseMinutesInfoDismissed === 'true' : DEFAULT_PREFERENCES.fitnessExerciseMinutesInfoDismissed, fitnessActiveHoursInfoDismissed: fitnessActiveHoursInfoDismissed !== null ? fitnessActiveHoursInfoDismissed === 'true' : DEFAULT_PREFERENCES.fitnessActiveHoursInfoDismissed, + waterReminderEnabled: waterReminderEnabled !== null ? waterReminderEnabled === 'true' : DEFAULT_PREFERENCES.waterReminderEnabled, + waterReminderStartTime: waterReminderStartTime || DEFAULT_PREFERENCES.waterReminderStartTime, + waterReminderEndTime: waterReminderEndTime || DEFAULT_PREFERENCES.waterReminderEndTime, + waterReminderInterval: waterReminderInterval ? parseInt(waterReminderInterval, 10) : DEFAULT_PREFERENCES.waterReminderInterval, }; } catch (error) { console.error('获取用户偏好设置失败:', error); @@ -185,6 +205,163 @@ export const getFitnessActiveHoursInfoDismissed = async (): Promise => } }; +/** + * 设置喝水提醒开关 + * @param enabled 是否开启喝水提醒 + */ +export const setWaterReminderEnabled = async (enabled: boolean): Promise => { + try { + await AsyncStorage.setItem(PREFERENCES_KEYS.WATER_REMINDER_ENABLED, enabled.toString()); + } catch (error) { + console.error('设置喝水提醒开关失败:', error); + throw error; + } +}; + +/** + * 获取喝水提醒开关状态 + */ +export const getWaterReminderEnabled = async (): Promise => { + try { + const enabled = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_ENABLED); + return enabled !== null ? enabled === 'true' : DEFAULT_PREFERENCES.waterReminderEnabled; + } catch (error) { + console.error('获取喝水提醒开关状态失败:', error); + return DEFAULT_PREFERENCES.waterReminderEnabled; + } +}; + +/** + * 设置喝水提醒开始时间 + * @param startTime 开始时间,格式为 "HH:mm" + */ +export const setWaterReminderStartTime = async (startTime: string): Promise => { + try { + await AsyncStorage.setItem(PREFERENCES_KEYS.WATER_REMINDER_START_TIME, startTime); + } catch (error) { + console.error('设置喝水提醒开始时间失败:', error); + throw error; + } +}; + +/** + * 获取喝水提醒开始时间 + */ +export const getWaterReminderStartTime = async (): Promise => { + try { + const startTime = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_START_TIME); + return startTime || DEFAULT_PREFERENCES.waterReminderStartTime; + } catch (error) { + console.error('获取喝水提醒开始时间失败:', error); + return DEFAULT_PREFERENCES.waterReminderStartTime; + } +}; + +/** + * 设置喝水提醒结束时间 + * @param endTime 结束时间,格式为 "HH:mm" + */ +export const setWaterReminderEndTime = async (endTime: string): Promise => { + try { + await AsyncStorage.setItem(PREFERENCES_KEYS.WATER_REMINDER_END_TIME, endTime); + } catch (error) { + console.error('设置喝水提醒结束时间失败:', error); + throw error; + } +}; + +/** + * 获取喝水提醒结束时间 + */ +export const getWaterReminderEndTime = async (): Promise => { + try { + const endTime = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_END_TIME); + return endTime || DEFAULT_PREFERENCES.waterReminderEndTime; + } catch (error) { + console.error('获取喝水提醒结束时间失败:', error); + return DEFAULT_PREFERENCES.waterReminderEndTime; + } +}; + +/** + * 设置喝水提醒间隔时间 + * @param interval 间隔时间(分钟),范围 30-180 + */ +export const setWaterReminderInterval = async (interval: number): Promise => { + try { + // 确保值在合理范围内(30-180分钟) + const validInterval = Math.max(30, Math.min(180, interval)); + await AsyncStorage.setItem(PREFERENCES_KEYS.WATER_REMINDER_INTERVAL, validInterval.toString()); + } catch (error) { + console.error('设置喝水提醒间隔时间失败:', error); + throw error; + } +}; + +/** + * 获取喝水提醒间隔时间 + */ +export const getWaterReminderInterval = async (): Promise => { + try { + const interval = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_INTERVAL); + return interval ? parseInt(interval, 10) : DEFAULT_PREFERENCES.waterReminderInterval; + } catch (error) { + console.error('获取喝水提醒间隔时间失败:', error); + return DEFAULT_PREFERENCES.waterReminderInterval; + } +}; + +/** + * 获取完整的喝水提醒配置 + */ +export const getWaterReminderSettings = async () => { + try { + const [enabled, startTime, endTime, interval] = await Promise.all([ + getWaterReminderEnabled(), + getWaterReminderStartTime(), + getWaterReminderEndTime(), + getWaterReminderInterval(), + ]); + + return { + enabled, + startTime, + endTime, + interval, + }; + } catch (error) { + console.error('获取喝水提醒配置失败:', error); + return { + enabled: DEFAULT_PREFERENCES.waterReminderEnabled, + startTime: DEFAULT_PREFERENCES.waterReminderStartTime, + endTime: DEFAULT_PREFERENCES.waterReminderEndTime, + interval: DEFAULT_PREFERENCES.waterReminderInterval, + }; + } +}; + +/** + * 批量设置喝水提醒配置 + */ +export const setWaterReminderSettings = async (settings: { + enabled: boolean; + startTime: string; + endTime: string; + interval: number; +}): Promise => { + try { + await Promise.all([ + setWaterReminderEnabled(settings.enabled), + setWaterReminderStartTime(settings.startTime), + setWaterReminderEndTime(settings.endTime), + setWaterReminderInterval(settings.interval), + ]); + } catch (error) { + console.error('批量设置喝水提醒配置失败:', error); + throw error; + } +}; + /** * 重置所有用户偏好设置为默认值 */ @@ -194,6 +371,10 @@ export const resetUserPreferences = async (): Promise => { await AsyncStorage.removeItem(PREFERENCES_KEYS.NOTIFICATION_ENABLED); await AsyncStorage.removeItem(PREFERENCES_KEYS.FITNESS_EXERCISE_MINUTES_INFO_DISMISSED); await AsyncStorage.removeItem(PREFERENCES_KEYS.FITNESS_ACTIVE_HOURS_INFO_DISMISSED); + await AsyncStorage.removeItem(PREFERENCES_KEYS.WATER_REMINDER_ENABLED); + await AsyncStorage.removeItem(PREFERENCES_KEYS.WATER_REMINDER_START_TIME); + await AsyncStorage.removeItem(PREFERENCES_KEYS.WATER_REMINDER_END_TIME); + await AsyncStorage.removeItem(PREFERENCES_KEYS.WATER_REMINDER_INTERVAL); } catch (error) { console.error('重置用户偏好设置失败:', error); throw error;