Files
digital-pilates/app/water/reminder-settings.tsx
richarjiang a014998848 feat(water): 重构饮水模块并新增自定义提醒设置功能
- 新增饮水详情页面 `/water/detail` 展示每日饮水记录与统计
- 新增饮水设置页面 `/water/settings` 支持目标与快速添加配置
- 新增喝水提醒设置页面 `/water/reminder-settings` 支持自定义时间段与间隔
- 重构 `useWaterData` Hook,支持按日期查询与实时刷新
- 新增 `WaterNotificationHelpers.scheduleCustomWaterReminders` 实现个性化提醒
- 优化心情编辑页键盘体验,新增 `KeyboardAvoidingView` 与滚动逻辑
- 升级版本号至 1.0.14 并补充路由常量
- 补充用户偏好存储字段 `waterReminderEnabled/startTime/endTime/interval`
- 废弃后台定时任务中的旧版喝水提醒逻辑,改为用户手动管理
2025-09-26 11:02:17 +08:00

618 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<HeaderBar
title="喝水提醒"
onBack={() => {
router.back();
}}
/>
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 开启/关闭提醒 */}
<View style={styles.waterReminderSection}>
<View style={styles.waterReminderSectionHeader}>
<View style={styles.waterReminderSectionTitleContainer}>
<Ionicons name="notifications-outline" size={20} color={colorTokens.text} />
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text>
</View>
<Switch
value={waterReminderSettings.enabled}
onValueChange={(enabled) => setWaterReminderSettings(prev => ({ ...prev, enabled }))}
trackColor={{ false: '#E5E5E5', true: '#3498DB' }}
thumbColor={waterReminderSettings.enabled ? '#FFFFFF' : '#FFFFFF'}
/>
</View>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
</Text>
</View>
{/* 时间段设置 */}
{waterReminderSettings.enabled && (
<>
<View style={styles.waterReminderSection}>
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
</Text>
<View style={styles.timeRangeContainer}>
{/* 开始时间 */}
<View style={styles.timePickerContainer}>
<Text style={[styles.timeLabel, { color: colorTokens.text }]}></Text>
<Pressable
style={[styles.timePicker, { backgroundColor: 'white' }]}
onPress={openStartTimePicker}
>
<Text style={[styles.timePickerText, { color: colorTokens.text }]}>{waterReminderSettings.startTime}</Text>
<Ionicons name="chevron-down" size={16} color={colorTokens.textSecondary} style={styles.timePickerIcon} />
</Pressable>
</View>
{/* 结束时间 */}
<View style={styles.timePickerContainer}>
<Text style={[styles.timeLabel, { color: colorTokens.text }]}></Text>
<Pressable
style={[styles.timePicker, { backgroundColor: 'white' }]}
onPress={openEndTimePicker}
>
<Text style={[styles.timePickerText, { color: colorTokens.text }]}>{waterReminderSettings.endTime}</Text>
<Ionicons name="chevron-down" size={16} color={colorTokens.textSecondary} style={styles.timePickerIcon} />
</Pressable>
</View>
</View>
</View>
{/* 提醒间隔设置 */}
<View style={styles.waterReminderSection}>
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
30-120
</Text>
<View style={styles.intervalContainer}>
<View style={styles.intervalPickerContainer}>
<Picker
selectedValue={waterReminderSettings.interval}
onValueChange={(interval) => setWaterReminderSettings(prev => ({ ...prev, interval }))}
style={styles.intervalPicker}
>
{[30, 45, 60, 90, 120, 150, 180].map(interval => (
<Picker.Item key={interval} label={`${interval}分钟`} value={interval} />
))}
</Picker>
</View>
</View>
</View>
</>
)}
{/* 保存按钮 */}
<View style={styles.saveButtonContainer}>
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: colorTokens.primary }]}
onPress={handleWaterReminderSave}
activeOpacity={0.8}
>
<Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}></Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
{/* 开始时间选择器弹窗 */}
<Modal
visible={startTimePickerVisible}
transparent
animationType="fade"
onRequestClose={() => setStartTimePickerVisible(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setStartTimePickerVisible(false)} />
<View style={styles.timePickerModalSheet}>
<View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<View style={styles.timePickerContent}>
<View style={styles.timePickerSection}>
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}></Text>
<View style={styles.hourPickerContainer}>
<Picker
selectedValue={tempStartHour}
onValueChange={(hour) => setTempStartHour(hour)}
style={styles.hourPicker}
>
{Array.from({ length: 24 }, (_, i) => (
<Picker.Item key={i} label={`${String(i).padStart(2, '0')}:00`} value={i} />
))}
</Picker>
</View>
</View>
<View style={styles.timeRangePreview}>
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
{String(tempStartHour).padStart(2, '0')}:00 - {waterReminderSettings.endTime}
</Text>
{!isValidTimeRange(`${String(tempStartHour).padStart(2, '0')}:00`, waterReminderSettings.endTime) && (
<Text style={styles.timeRangeWarning}> </Text>
)}
</View>
</View>
<View style={styles.modalActions}>
<Pressable
onPress={() => setStartTimePickerVisible(false)}
style={[styles.modalBtn, { backgroundColor: 'white' }]}
>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text>
</Pressable>
<Pressable
onPress={confirmStartTime}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text>
</Pressable>
</View>
</View>
</Modal>
{/* 结束时间选择器弹窗 */}
<Modal
visible={endTimePickerVisible}
transparent
animationType="fade"
onRequestClose={() => setEndTimePickerVisible(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setEndTimePickerVisible(false)} />
<View style={styles.timePickerModalSheet}>
<View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<View style={styles.timePickerContent}>
<View style={styles.timePickerSection}>
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}></Text>
<View style={styles.hourPickerContainer}>
<Picker
selectedValue={tempEndHour}
onValueChange={(hour) => setTempEndHour(hour)}
style={styles.hourPicker}
>
{Array.from({ length: 24 }, (_, i) => (
<Picker.Item key={i} label={`${String(i).padStart(2, '0')}:00`} value={i} />
))}
</Picker>
</View>
</View>
<View style={styles.timeRangePreview}>
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
{waterReminderSettings.startTime} - {String(tempEndHour).padStart(2, '0')}:00
</Text>
{!isValidTimeRange(waterReminderSettings.startTime, `${String(tempEndHour).padStart(2, '0')}:00`) && (
<Text style={styles.timeRangeWarning}> </Text>
)}
</View>
</View>
<View style={styles.modalActions}>
<Pressable
onPress={() => setEndTimePickerVisible(false)}
style={[styles.modalBtn, { backgroundColor: 'white' }]}
>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text>
</Pressable>
<Pressable
onPress={confirmEndTime}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text>
</Pressable>
</View>
</View>
</Modal>
</View>
);
};
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;