diff --git a/app/(tabs)/medications.tsx b/app/(tabs)/medications.tsx index 98c1cb2..4b690d0 100644 --- a/app/(tabs)/medications.tsx +++ b/app/(tabs)/medications.tsx @@ -5,6 +5,7 @@ import { IconSymbol } from '@/components/ui/IconSymbol'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { medicationNotificationService } from '@/services/medicationNotifications'; import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice'; import { DEFAULT_MEMBER_NAME } from '@/store/userSlice'; import { useFocusEffect } from '@react-navigation/native'; @@ -67,8 +68,23 @@ export default function MedicationsScreen() { // 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据 useFocusEffect( useCallback(() => { - dispatch(fetchMedications({ isActive: true })); - dispatch(fetchMedicationRecords({ date: selectedKey })); + // 重新安排药品通知并刷新数据 + const refreshDataAndRescheduleNotifications = async () => { + try { + // 只获取一次药物数据,然后复用结果 + const medications = await dispatch(fetchMedications({ isActive: true })).unwrap(); + + // 并行执行获取药物记录和安排通知 + await Promise.all([ + dispatch(fetchMedicationRecords({ date: selectedKey })), + medicationNotificationService.rescheduleAllMedicationNotifications(medications), + ]); + } catch (error) { + console.error('刷新数据或重新安排药品通知失败:', error); + } + }; + + refreshDataAndRescheduleNotifications(); }, [dispatch, selectedKey]) ); @@ -78,8 +94,6 @@ export default function MedicationsScreen() { // 为每个药物添加默认图片(如果没有图片) const medicationsWithImages = useMemo(() => { - console.log('medicationsForDay', medicationsForDay); - return medicationsForDay.map((med: any) => ({ ...med, image: med.image || require('@/assets/images/medicine/image-medicine.png'), // 默认使用瓶子图标 diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 17d424c..6f12ad3 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -10,7 +10,6 @@ import { selectActiveMembershipPlanName } from '@/store/membershipSlice'; import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice'; import { getItem, setItem } from '@/utils/kvStore'; import { log } from '@/utils/logger'; -import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences'; import { Ionicons } from '@expo/vector-icons'; import { useFocusEffect } from '@react-navigation/native'; import dayjs from 'dayjs'; @@ -18,7 +17,7 @@ import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; +import { Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg'; @@ -37,7 +36,7 @@ export default function PersonalScreen() { sendNotification, } = useNotifications(); - const [notificationEnabled, setNotificationEnabled] = useState(false); + // 移除 notificationEnabled 状态,因为现在在通知设置页面中管理 // 开发者模式相关状态 const [showDeveloperSection, setShowDeveloperSection] = useState(false); @@ -67,22 +66,13 @@ export default function PersonalScreen() { React.useCallback(() => { dispatch(fetchMyProfile()); dispatch(fetchActivityHistory()); - // 加载用户推送偏好设置 - loadNotificationPreference(); + // 不再需要在这里加载推送偏好设置,因为已移到通知设置页面 // 加载开发者模式状态 loadDeveloperModeState(); }, [dispatch]) ); - // 加载用户推送偏好设置 - const loadNotificationPreference = async () => { - try { - const enabled = await getNotificationEnabled(); - setNotificationEnabled(enabled); - } catch (error) { - console.error('加载推送偏好设置失败:', error); - } - }; + // 移除 loadNotificationPreference 函数,因为已移到通知设置页面 // 加载开发者模式状态 const loadDeveloperModeState = async () => { @@ -127,9 +117,8 @@ export default function PersonalScreen() { // 显示名称 const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME; - // 初始化时加载推送偏好设置和开发者模式状态 + // 初始化时只加载开发者模式状态 useEffect(() => { - loadNotificationPreference(); loadDeveloperModeState(); }, []); @@ -160,50 +149,7 @@ export default function PersonalScreen() { } }; - // 处理通知开关变化 - const handleNotificationToggle = async (value: boolean) => { - if (value) { - try { - // 先检查系统权限 - const status = await requestPermission(); - if (status === 'granted') { - // 系统权限获取成功,保存用户偏好设置 - await saveNotificationEnabled(true); - setNotificationEnabled(true); - - // 发送测试通知 - await sendNotification({ - title: '通知已开启', - body: '您将收到运动提醒和重要通知', - sound: true, - priority: 'normal', - }); - } else { - // 系统权限被拒绝,不更新用户偏好设置 - Alert.alert( - '权限被拒绝', - '请在系统设置中开启通知权限,然后再尝试开启推送功能', - [ - { text: '取消', style: 'cancel' }, - { text: '去设置', onPress: () => Linking.openSettings() } - ] - ); - } - } catch (error) { - console.error('开启推送通知失败:', error); - Alert.alert('错误', '请求通知权限失败'); - } - } else { - try { - // 关闭推送,保存用户偏好设置 - await saveNotificationEnabled(false); - setNotificationEnabled(false); - } catch (error) { - console.error('关闭推送通知失败:', error); - Alert.alert('错误', '保存设置失败'); - } - } - }; + // 移除 handleNotificationToggle 函数,因为已移到通知设置页面 // 用户信息头部 const UserHeader = () => ( @@ -412,10 +358,8 @@ export default function PersonalScreen() { items: [ { icon: 'notifications-outline' as const, - title: '消息推送', - type: 'switch' as const, - switchValue: notificationEnabled, - onSwitchChange: handleNotificationToggle, + title: '通知设置', + onPress: () => pushIfAuthedElseLogin(ROUTES.NOTIFICATION_SETTINGS), } ], }, diff --git a/app/medications/[medicationId].tsx b/app/medications/[medicationId].tsx index b78d7b1..3e8d00a 100644 --- a/app/medications/[medicationId].tsx +++ b/app/medications/[medicationId].tsx @@ -3,9 +3,11 @@ import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet'; import { HeaderBar } from '@/components/ui/HeaderBar'; import InfoCard from '@/components/ui/InfoCard'; import { Colors } from '@/constants/Colors'; -import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_LABELS, FORM_OPTIONS, TIMES_PER_DAY_OPTIONS } from '@/constants/Medication'; +import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_LABELS, FORM_OPTIONS } from '@/constants/Medication'; +import { ROUTES } from '@/constants/Routes'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { medicationNotificationService } from '@/services/medicationNotifications'; import { getMedicationById, getMedicationRecords } from '@/services/medications'; import { deleteMedicationAction, @@ -13,9 +15,8 @@ import { selectMedications, updateMedicationAction, } from '@/store/medicationsSlice'; -import type { Medication, MedicationForm, RepeatPattern } from '@/types/medication'; +import type { Medication, MedicationForm } from '@/types/medication'; import { Ionicons } from '@expo/vector-icons'; -import DateTimePicker from '@react-native-community/datetimepicker'; import { Picker } from '@react-native-picker/picker'; import Voice from '@react-native-voice/voice'; import dayjs from 'dayjs'; @@ -96,69 +97,6 @@ export default function MedicationDetailScreen() { const [formPicker, setFormPicker] = useState( medicationFromStore?.form ?? 'capsule' ); - - // 频率选择相关状态 - const [frequencyPickerVisible, setFrequencyPickerVisible] = useState(false); - const [repeatPatternPicker, setRepeatPatternPicker] = useState( - medicationFromStore?.repeatPattern ?? 'daily' - ); - const [timesPerDayPicker, setTimesPerDayPicker] = useState( - medicationFromStore?.timesPerDay ?? 1 - ); - - // 提醒时间相关状态 - const [timePickerVisible, setTimePickerVisible] = useState(false); - const [timePickerDate, setTimePickerDate] = useState(new Date()); - const [editingTimeIndex, setEditingTimeIndex] = useState(null); - const [medicationTimesPicker, setMedicationTimesPicker] = useState( - medicationFromStore?.medicationTimes ?? [] - ); - - // 辅助函数:从时间字符串创建 Date 对象 - const createDateFromTime = useCallback((time: string) => { - try { - if (!time || typeof time !== 'string') { - console.warn('[MEDICATION] Invalid time string provided:', time); - return new Date(); - } - - const parts = time.split(':'); - if (parts.length !== 2) { - console.warn('[MEDICATION] Invalid time format:', time); - return new Date(); - } - - const hour = parseInt(parts[0], 10); - const minute = parseInt(parts[1], 10); - - if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { - console.warn('[MEDICATION] Invalid time values:', { hour, minute }); - return new Date(); - } - - const next = new Date(); - next.setHours(hour, minute, 0, 0); - - if (isNaN(next.getTime())) { - console.error('[MEDICATION] Failed to create valid date'); - return new Date(); - } - - return next; - } catch (error) { - console.error('[MEDICATION] Error in createDateFromTime:', error); - return new Date(); - } - }, []); - - // 辅助函数:格式化时间 - const formatTime = useCallback((date: Date) => dayjs(date).format('HH:mm'), []); - - // 辅助函数:获取默认时间 - const getDefaultTimeByIndex = useCallback((index: number) => { - const DEFAULT_TIME_PRESETS = ['08:00', '12:00', '18:00', '22:00']; - return DEFAULT_TIME_PRESETS[index % DEFAULT_TIME_PRESETS.length]; - }, []); useEffect(() => { if (!medicationFromStore) { @@ -174,35 +112,13 @@ export default function MedicationDetailScreen() { }, [medicationFromStore]); useEffect(() => { - // 同步剂量选择器、剂型选择器、频率选择器和时间选择器的默认值 + // 同步剂量选择器和剂型选择器的默认值 if (medication) { setDosageValuePicker(medication.dosageValue); setDosageUnitPicker(medication.dosageUnit); setFormPicker(medication.form); - setRepeatPatternPicker(medication.repeatPattern); - setTimesPerDayPicker(medication.timesPerDay); - setMedicationTimesPicker(medication.medicationTimes || []); } - }, [medication?.dosageValue, medication?.dosageUnit, medication?.form, medication?.repeatPattern, medication?.timesPerDay, medication?.medicationTimes]); - - // 根据 timesPerDayPicker 动态调整 medicationTimesPicker(与 add-medication.tsx 逻辑一致) - useEffect(() => { - setMedicationTimesPicker((prev) => { - if (timesPerDayPicker > prev.length) { - // 需要添加更多时间 - const next = [...prev]; - while (next.length < timesPerDayPicker) { - next.push(getDefaultTimeByIndex(next.length)); - } - return next; - } - if (timesPerDayPicker < prev.length) { - // 需要删除多余时间 - return prev.slice(0, timesPerDayPicker); - } - return prev; - }); - }, [timesPerDayPicker, getDefaultTimeByIndex]); + }, [medication?.dosageValue, medication?.dosageUnit, medication?.form]); useEffect(() => { setNoteDraft(medication?.note ?? ''); @@ -434,6 +350,20 @@ export default function MedicationDetailScreen() { }) ).unwrap(); setMedication(updated); + + // 重新安排药品通知 + try { + if (nextValue) { + // 如果激活了药品,安排通知 + await medicationNotificationService.scheduleMedicationNotifications(updated); + } else { + // 如果停用了药品,取消通知 + await medicationNotificationService.cancelMedicationNotifications(updated.id); + } + } catch (error) { + console.error('[MEDICATION] 处理药品通知失败:', error); + // 不影响药品状态切换的成功流程,只记录错误 + } } catch (err) { console.error('切换药品状态失败', err); Alert.alert('操作失败', '切换提醒状态时出现问题,请稍后重试。'); @@ -509,6 +439,15 @@ export default function MedicationDetailScreen() { try { setDeleteLoading(true); setDeleteSheetVisible(false); // 立即关闭确认对话框 + + // 先取消该药品的通知 + try { + await medicationNotificationService.cancelMedicationNotifications(medication.id); + } catch (error) { + console.error('[MEDICATION] 取消药品通知失败:', error); + // 不影响药品删除的成功流程,只记录错误 + } + await dispatch(deleteMedicationAction(medication.id)).unwrap(); console.log('[MEDICATION_DETAIL] Delete operation successful, navigating back'); router.back(); @@ -546,8 +485,18 @@ export default function MedicationDetailScreen() { const handleFrequencyPress = useCallback(() => { if (!medication) return; - setFrequencyPickerVisible(true); - }, [medication]); + // 跳转到独立的频率编辑页面 + router.push({ + pathname: ROUTES.MEDICATION_EDIT_FREQUENCY, + params: { + medicationId: medication.id, + medicationName: medication.name, + repeatPattern: medication.repeatPattern, + timesPerDay: medication.timesPerDay.toString(), + medicationTimes: medication.medicationTimes.join(','), + }, + }); + }, [medication, router]); const confirmDosagePicker = useCallback(async () => { if (!medication || updatePending) return; @@ -569,6 +518,14 @@ export default function MedicationDetailScreen() { }) ).unwrap(); setMedication(updated); + + // 重新安排药品通知 + try { + await medicationNotificationService.scheduleMedicationNotifications(updated); + } catch (error) { + console.error('[MEDICATION] 安排药品通知失败:', error); + // 不影响药品更新的成功流程,只记录错误 + } } catch (err) { console.error('更新剂量失败', err); Alert.alert('更新失败', '更新剂量时出现问题,请稍后重试。'); @@ -596,6 +553,14 @@ export default function MedicationDetailScreen() { }) ).unwrap(); setMedication(updated); + + // 重新安排药品通知 + try { + await medicationNotificationService.scheduleMedicationNotifications(updated); + } catch (error) { + console.error('[MEDICATION] 安排药品通知失败:', error); + // 不影响药品更新的成功流程,只记录错误 + } } catch (err) { console.error('更新剂型失败', err); Alert.alert('更新失败', '更新剂型时出现问题,请稍后重试。'); @@ -603,110 +568,6 @@ export default function MedicationDetailScreen() { setUpdatePending(false); } }, [dispatch, formPicker, medication, updatePending]); - - const confirmFrequencyPicker = useCallback(async () => { - if (!medication || updatePending) return; - - setFrequencyPickerVisible(false); - - // 检查频率和时间是否都没有变化 - const frequencyChanged = repeatPatternPicker !== medication.repeatPattern || timesPerDayPicker !== medication.timesPerDay; - const timesChanged = JSON.stringify(medicationTimesPicker) !== JSON.stringify(medication.medicationTimes); - - if (!frequencyChanged && !timesChanged) { - return; - } - - try { - setUpdatePending(true); - const updated = await dispatch( - updateMedicationAction({ - id: medication.id, - repeatPattern: repeatPatternPicker, - timesPerDay: timesPerDayPicker, - medicationTimes: medicationTimesPicker, // 同时更新提醒时间 - }) - ).unwrap(); - setMedication(updated); - } catch (err) { - console.error('更新频率失败', err); - Alert.alert('更新失败', '更新服药频率时出现问题,请稍后重试。'); - } finally { - setUpdatePending(false); - } - }, [dispatch, repeatPatternPicker, timesPerDayPicker, medicationTimesPicker, medication, updatePending]); - - // 打开时间选择器 - const openTimePicker = useCallback( - (index?: number) => { - try { - if (typeof index === 'number') { - if (index >= 0 && index < medicationTimesPicker.length) { - setEditingTimeIndex(index); - setTimePickerDate(createDateFromTime(medicationTimesPicker[index])); - } else { - console.error('[MEDICATION] Invalid time index:', index); - return; - } - } else { - setEditingTimeIndex(null); - setTimePickerDate(createDateFromTime(getDefaultTimeByIndex(medicationTimesPicker.length))); - } - setTimePickerVisible(true); - } catch (error) { - console.error('[MEDICATION] Error in openTimePicker:', error); - } - }, - [medicationTimesPicker, createDateFromTime, getDefaultTimeByIndex] - ); - - // 确认时间选择 - const confirmTime = useCallback( - (date: Date) => { - try { - if (!date || isNaN(date.getTime())) { - console.error('[MEDICATION] Invalid date provided to confirmTime'); - setTimePickerVisible(false); - setEditingTimeIndex(null); - return; - } - - const nextValue = formatTime(date); - setMedicationTimesPicker((prev) => { - if (editingTimeIndex == null) { - return [...prev, nextValue]; - } - return prev.map((time, idx) => (idx === editingTimeIndex ? nextValue : time)); - }); - setTimePickerVisible(false); - setEditingTimeIndex(null); - } catch (error) { - console.error('[MEDICATION] Error in confirmTime:', error); - setTimePickerVisible(false); - setEditingTimeIndex(null); - } - }, - [editingTimeIndex, formatTime] - ); - - // 删除时间 - const removeTime = useCallback((index: number) => { - setMedicationTimesPicker((prev) => { - if (prev.length === 1) { - return prev; // 至少保留一个时间 - } - return prev.filter((_, idx) => idx !== index); - }); - // 同时更新 timesPerDayPicker - setTimesPerDayPicker((prev) => Math.max(1, prev - 1)); - }, []); - - // 添加时间 - const addTime = useCallback(() => { - openTimePicker(); - // 同时更新 timesPerDayPicker - setTimesPerDayPicker((prev) => prev + 1); - }, [openTimePicker]); if (!medicationId) { return ( @@ -889,7 +750,6 @@ export default function MedicationDetailScreen() { styles.footerBar, { paddingBottom: Math.max(insets.bottom, 18), - backgroundColor: colors.pageBackgroundEmphasis, }, ]} > @@ -1138,178 +998,6 @@ export default function MedicationDetailScreen() { - setFrequencyPickerVisible(false)} - > - setFrequencyPickerVisible(false)} - /> - - - 选择服药频率 - - - - - 重复模式 - - setRepeatPatternPicker(value as RepeatPattern)} - itemStyle={styles.pickerItem} - style={styles.picker} - > - - {/* - */} - - - - - 每日次数 - - setTimesPerDayPicker(Number(value))} - itemStyle={styles.pickerItem} - style={styles.picker} - > - {TIMES_PER_DAY_OPTIONS.map((times) => ( - - ))} - - - - - {/* 提醒时间列表 */} - - - 每日提醒时间 - - - {medicationTimesPicker.map((time, index) => ( - - openTimePicker(index)}> - - {time} - - removeTime(index)} disabled={medicationTimesPicker.length === 1} hitSlop={12}> - - - - ))} - - - 添加时间 - - - - - - setFrequencyPickerVisible(false)} - style={[styles.pickerBtn, { borderColor: colors.border }]} - > - - 取消 - - - - - 确定 - - - - - - - {/* 时间选择器 Modal */} - { - setTimePickerVisible(false); - setEditingTimeIndex(null); - }} - > - { - setTimePickerVisible(false); - setEditingTimeIndex(null); - }} - /> - - - {editingTimeIndex !== null ? '修改提醒时间' : '添加提醒时间'} - - { - if (Platform.OS === 'ios') { - if (date) setTimePickerDate(date); - } else { - if (event.type === 'set' && date) { - confirmTime(date); - } else { - setTimePickerVisible(false); - setEditingTimeIndex(null); - } - } - }} - /> - {Platform.OS === 'ios' && ( - - { - setTimePickerVisible(false); - setEditingTimeIndex(null); - }} - style={[styles.pickerBtn, { borderColor: colors.border }]} - > - 取消 - - confirmTime(timePickerDate)} - style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]} - > - 确定 - - - )} - - - {medication ? ( { - try { - const permission = await ImagePicker.requestCameraPermissionsAsync(); - if (permission.status !== 'granted') { - Alert.alert('权限不足', '需要相机权限以拍摄药品照片'); - return; - } + // 处理图片选择(拍照或相册) + const handleSelectPhoto = useCallback(() => { + Alert.alert( + '选择图片', + '请选择图片来源', + [ + { + text: '拍照', + onPress: async () => { + try { + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (permission.status !== 'granted') { + Alert.alert('权限不足', '需要相机权限以拍摄药品照片'); + return; + } - const result = await ImagePicker.launchCameraAsync({ - allowsEditing: true, - mediaTypes: ImagePicker.MediaTypeOptions.Images, - quality: 0.9, - }); + const result = await ImagePicker.launchCameraAsync({ + allowsEditing: true, + quality: 0.9, + aspect: [9,16] + }); - if (result.canceled || !result.assets?.length) { - return; - } + if (result.canceled || !result.assets?.length) { + return; + } - const asset = result.assets[0]; - setPhotoPreview(asset.uri); - setPhotoUrl(null); + const asset = result.assets[0]; + setPhotoPreview(asset.uri); + setPhotoUrl(null); - try { - const { url } = await upload( - { uri: asset.uri, name: asset.fileName ?? `medication-${Date.now()}.jpg`, type: asset.mimeType ?? 'image/jpeg' }, - { prefix: 'images/medications' } - ); - setPhotoUrl(url); - } catch (uploadError) { - console.error('[MEDICATION] 图片上传失败', uploadError); - Alert.alert('上传失败', '图片上传失败,请稍后重试'); - } - } catch (error) { - console.error('[MEDICATION] 拍照失败', error); - Alert.alert('拍照失败', '无法打开相机,请稍后再试'); - } + try { + const { url } = await upload( + { uri: asset.uri, name: asset.fileName ?? `medication-${Date.now()}.jpg`, type: asset.mimeType ?? 'image/jpeg' }, + { prefix: 'images/medications' } + ); + setPhotoUrl(url); + } catch (uploadError) { + console.error('[MEDICATION] 图片上传失败', uploadError); + Alert.alert('上传失败', '图片上传失败,请稍后重试'); + } + } catch (error) { + console.error('[MEDICATION] 拍照失败', error); + Alert.alert('拍照失败', '无法打开相机,请稍后再试'); + } + }, + }, + { + text: '从相册选择', + onPress: async () => { + try { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (permission.status !== 'granted') { + Alert.alert('权限不足', '需要相册权限以选择药品照片'); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + allowsEditing: true, + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: 0.9, + }); + + if (result.canceled || !result.assets?.length) { + return; + } + + const asset = result.assets[0]; + setPhotoPreview(asset.uri); + setPhotoUrl(null); + + try { + const { url } = await upload( + { uri: asset.uri, name: asset.fileName ?? `medication-${Date.now()}.jpg`, type: asset.mimeType ?? 'image/jpeg' }, + { prefix: 'images/medications' } + ); + setPhotoUrl(url); + } catch (uploadError) { + console.error('[MEDICATION] 图片上传失败', uploadError); + Alert.alert('上传失败', '图片上传失败,请稍后重试'); + } + } catch (error) { + console.error('[MEDICATION] 从相册选择失败', error); + Alert.alert('选择失败', '无法打开相册,请稍后再试'); + } + }, + }, + { + text: '取消', + style: 'cancel', + }, + ], + { cancelable: true } + ); }, [upload]); const handleRemovePhoto = useCallback(() => { @@ -539,7 +607,7 @@ export default function AddMedicationScreen() { backgroundColor: colors.surface, }, ]} - onPress={handleTakePhoto} + onPress={handleSelectPhoto} disabled={uploading} > {photoPreview ? ( @@ -548,7 +616,7 @@ export default function AddMedicationScreen() { - {uploading ? '上传中…' : '重新拍摄'} + {uploading ? '上传中…' : '重新选择'} @@ -560,8 +628,8 @@ export default function AddMedicationScreen() { - 拍照上传药品图片 - 辅助识别药品包装,更易区分 + 上传药品图片 + 拍照或从相册选择,辅助识别药品包装 )} {uploading && ( diff --git a/app/medications/edit-frequency.tsx b/app/medications/edit-frequency.tsx new file mode 100644 index 0000000..14aca0e --- /dev/null +++ b/app/medications/edit-frequency.tsx @@ -0,0 +1,680 @@ +import { ThemedText } from '@/components/ThemedText'; +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { Colors } from '@/constants/Colors'; +import { TIMES_PER_DAY_OPTIONS } from '@/constants/Medication'; +import { useAppDispatch } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { medicationNotificationService } from '@/services/medicationNotifications'; +import { updateMedicationAction } from '@/store/medicationsSlice'; +import type { RepeatPattern } from '@/types/medication'; +import { Ionicons } from '@expo/vector-icons'; +import DateTimePicker from '@react-native-community/datetimepicker'; +import { Picker } from '@react-native-picker/picker'; +import dayjs from 'dayjs'; +import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const DEFAULT_TIME_PRESETS = ['08:00', '12:00', '18:00', '22:00']; + +// 辅助函数:从时间字符串创建 Date 对象 +const createDateFromTime = (time: string) => { + try { + if (!time || typeof time !== 'string') { + console.warn('[MEDICATION] Invalid time string provided:', time); + return new Date(); + } + + const parts = time.split(':'); + if (parts.length !== 2) { + console.warn('[MEDICATION] Invalid time format:', time); + return new Date(); + } + + const hour = parseInt(parts[0], 10); + const minute = parseInt(parts[1], 10); + + if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { + console.warn('[MEDICATION] Invalid time values:', { hour, minute }); + return new Date(); + } + + const next = new Date(); + next.setHours(hour, minute, 0, 0); + + if (isNaN(next.getTime())) { + console.error('[MEDICATION] Failed to create valid date'); + return new Date(); + } + + return next; + } catch (error) { + console.error('[MEDICATION] Error in createDateFromTime:', error); + return new Date(); + } +}; + +// 辅助函数:格式化时间 +const formatTime = (date: Date) => dayjs(date).format('HH:mm'); + +// 辅助函数:获取默认时间 +const getDefaultTimeByIndex = (index: number) => { + return DEFAULT_TIME_PRESETS[index % DEFAULT_TIME_PRESETS.length]; +}; + +export default function EditMedicationFrequencyScreen() { + const params = useLocalSearchParams<{ + medicationId?: string; + medicationName?: string; + repeatPattern?: RepeatPattern; + timesPerDay?: string; + medicationTimes?: string; + }>(); + + const medicationId = Array.isArray(params.medicationId) ? params.medicationId[0] : params.medicationId; + const medicationName = Array.isArray(params.medicationName) ? params.medicationName[0] : params.medicationName; + const initialRepeatPattern = (Array.isArray(params.repeatPattern) ? params.repeatPattern[0] : params.repeatPattern) as RepeatPattern || 'daily'; + const initialTimesPerDay = parseInt(Array.isArray(params.timesPerDay) ? params.timesPerDay[0] : params.timesPerDay || '1', 10); + const initialTimes = params.medicationTimes + ? (Array.isArray(params.medicationTimes) ? params.medicationTimes[0] : params.medicationTimes).split(',') + : ['08:00']; + + const dispatch = useAppDispatch(); + const router = useRouter(); + const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors; + const colors = Colors[scheme]; + const insets = useSafeAreaInsets(); + + const [repeatPattern, setRepeatPattern] = useState(initialRepeatPattern); + const [timesPerDay, setTimesPerDay] = useState(initialTimesPerDay); + const [medicationTimes, setMedicationTimes] = useState(initialTimes); + const [saving, setSaving] = useState(false); + + // 时间选择器相关状态 + const [timePickerVisible, setTimePickerVisible] = useState(false); + const [timePickerDate, setTimePickerDate] = useState(new Date()); + const [editingTimeIndex, setEditingTimeIndex] = useState(null); + + // 根据 timesPerDay 动态调整 medicationTimes + useEffect(() => { + setMedicationTimes((prev) => { + if (timesPerDay > prev.length) { + const next = [...prev]; + while (next.length < timesPerDay) { + next.push(getDefaultTimeByIndex(next.length)); + } + return next; + } + if (timesPerDay < prev.length) { + return prev.slice(0, timesPerDay); + } + return prev; + }); + }, [timesPerDay]); + + // 打开时间选择器 + const openTimePicker = useCallback( + (index?: number) => { + try { + if (typeof index === 'number') { + if (index >= 0 && index < medicationTimes.length) { + setEditingTimeIndex(index); + setTimePickerDate(createDateFromTime(medicationTimes[index])); + } else { + console.error('[MEDICATION] Invalid time index:', index); + return; + } + } else { + setEditingTimeIndex(null); + setTimePickerDate(createDateFromTime(getDefaultTimeByIndex(medicationTimes.length))); + } + setTimePickerVisible(true); + } catch (error) { + console.error('[MEDICATION] Error in openTimePicker:', error); + } + }, + [medicationTimes] + ); + + // 确认时间选择 + const confirmTime = useCallback( + (date: Date) => { + try { + if (!date || isNaN(date.getTime())) { + console.error('[MEDICATION] Invalid date provided to confirmTime'); + setTimePickerVisible(false); + setEditingTimeIndex(null); + return; + } + + const nextValue = formatTime(date); + setMedicationTimes((prev) => { + if (editingTimeIndex == null) { + return [...prev, nextValue]; + } + return prev.map((time, idx) => (idx === editingTimeIndex ? nextValue : time)); + }); + setTimePickerVisible(false); + setEditingTimeIndex(null); + } catch (error) { + console.error('[MEDICATION] Error in confirmTime:', error); + setTimePickerVisible(false); + setEditingTimeIndex(null); + } + }, + [editingTimeIndex] + ); + + // 删除时间 + const removeTime = useCallback((index: number) => { + setMedicationTimes((prev) => { + if (prev.length === 1) { + return prev; // 至少保留一个时间 + } + return prev.filter((_, idx) => idx !== index); + }); + // 同时更新 timesPerDay + setTimesPerDay((prev) => Math.max(1, prev - 1)); + }, []); + + // 添加时间 + const addTime = useCallback(() => { + openTimePicker(); + // 同时更新 timesPerDay + setTimesPerDay((prev) => prev + 1); + }, [openTimePicker]); + + // 保存修改 + const handleSave = useCallback(async () => { + if (!medicationId || saving) return; + + setSaving(true); + try { + const updated = await dispatch( + updateMedicationAction({ + id: medicationId, + repeatPattern, + timesPerDay, + medicationTimes, + }) + ).unwrap(); + + // 重新安排药品通知 + try { + await medicationNotificationService.scheduleMedicationNotifications(updated); + } catch (error) { + console.error('[MEDICATION] 安排药品通知失败:', error); + } + + router.back(); + } catch (err) { + console.error('更新频率失败', err); + Alert.alert('更新失败', '更新服药频率时出现问题,请稍后重试。'); + } finally { + setSaving(false); + } + }, [dispatch, medicationId, medicationTimes, repeatPattern, router, saving, timesPerDay]); + + const frequencyLabel = useMemo(() => { + switch (repeatPattern) { + case 'daily': + return `每日 ${timesPerDay} 次`; + case 'weekly': + return `每周 ${timesPerDay} 次`; + default: + return `自定义 · ${timesPerDay} 次/日`; + } + }, [repeatPattern, timesPerDay]); + + if (!medicationId) { + return ( + + + + 缺少必要参数 + + + ); + } + + return ( + + + + + {/* 药品名称提示 */} + {medicationName && ( + + + + {medicationName} + + + )} + + {/* 频率选择 */} + + + 服药频率 + + + 设置每日服药次数 + + + + + + 重复模式 + + setRepeatPattern(value as RepeatPattern)} + itemStyle={styles.pickerItem} + style={styles.picker} + > + + {/* + */} + + + + + 每日次数 + + setTimesPerDay(Number(value))} + itemStyle={styles.pickerItem} + style={styles.picker} + > + {TIMES_PER_DAY_OPTIONS.map((times) => ( + + ))} + + + + + + + + {frequencyLabel} + + + + + {/* 提醒时间列表 */} + + + 每日提醒时间 + + + 添加并管理每天的提醒时间 + + + + {medicationTimes.map((time, index) => ( + + openTimePicker(index)}> + + {time} + + removeTime(index)} + disabled={medicationTimes.length === 1} + hitSlop={12} + > + + + + ))} + + + + + 添加时间 + + + + + + + {/* 底部保存按钮 */} + + + {isLiquidGlassAvailable() ? ( + + {saving ? ( + + ) : ( + <> + + 保存修改 + + )} + + ) : ( + + {saving ? ( + + ) : ( + <> + + 保存修改 + + )} + + )} + + + + {/* 时间选择器 Modal */} + { + setTimePickerVisible(false); + setEditingTimeIndex(null); + }} + > + { + setTimePickerVisible(false); + setEditingTimeIndex(null); + }} + /> + + + {editingTimeIndex !== null ? '修改提醒时间' : '添加提醒时间'} + + { + if (Platform.OS === 'ios') { + if (date) setTimePickerDate(date); + } else { + if (event.type === 'set' && date) { + confirmTime(date); + } else { + setTimePickerVisible(false); + setEditingTimeIndex(null); + } + } + }} + /> + {Platform.OS === 'ios' && ( + + { + setTimePickerVisible(false); + setEditingTimeIndex(null); + }} + style={[styles.pickerBtn, { borderColor: colors.border }]} + > + + 取消 + + + confirmTime(timePickerDate)} + style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]} + > + + 确定 + + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + paddingHorizontal: 20, + gap: 32, + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + emptyText: { + fontSize: 16, + textAlign: 'center', + }, + medicationNameCard: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + borderRadius: 20, + paddingHorizontal: 18, + paddingVertical: 14, + }, + medicationNameText: { + fontSize: 16, + fontWeight: '600', + }, + section: { + gap: 16, + }, + sectionTitle: { + fontSize: 20, + fontWeight: '700', + }, + sectionDescription: { + fontSize: 14, + lineHeight: 20, + }, + pickerRow: { + flexDirection: 'row', + gap: 16, + }, + pickerColumn: { + flex: 1, + gap: 8, + }, + pickerLabel: { + fontSize: 14, + fontWeight: '600', + textAlign: 'center', + }, + picker: { + width: '100%', + height: 150, + }, + pickerItem: { + fontSize: 18, + height: 150, + }, + frequencySummary: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + borderRadius: 16, + paddingHorizontal: 16, + paddingVertical: 14, + }, + frequencySummaryText: { + fontSize: 16, + fontWeight: '600', + }, + timeList: { + gap: 12, + }, + timeItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: 1, + borderRadius: 16, + paddingHorizontal: 16, + paddingVertical: 14, + }, + timeValue: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + timeText: { + fontSize: 18, + fontWeight: '600', + }, + addTimeButton: { + borderWidth: 1, + borderRadius: 16, + paddingVertical: 14, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + addTimeLabel: { + fontSize: 15, + fontWeight: '600', + }, + footerBar: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingHorizontal: 20, + paddingTop: 16, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(15,23,42,0.06)', + }, + saveButton: { + height: 56, + borderRadius: 24, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + overflow: 'hidden', + }, + fallbackSaveButton: { + backgroundColor: '#7a5af8', + shadowColor: 'rgba(122, 90, 248, 0.4)', + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 1, + shadowRadius: 20, + elevation: 6, + }, + saveButtonText: { + fontSize: 17, + fontWeight: '700', + color: '#fff', + }, + pickerBackdrop: { + flex: 1, + backgroundColor: 'rgba(15, 23, 42, 0.4)', + }, + pickerSheet: { + position: 'absolute', + left: 20, + right: 20, + bottom: 40, + borderRadius: 24, + padding: 20, + shadowColor: '#000', + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.2, + shadowRadius: 20, + elevation: 8, + }, + pickerTitle: { + fontSize: 20, + fontWeight: '700', + marginBottom: 16, + textAlign: 'center', + }, + pickerActions: { + flexDirection: 'row', + gap: 12, + marginTop: 16, + }, + pickerBtn: { + flex: 1, + paddingVertical: 14, + borderRadius: 16, + alignItems: 'center', + borderWidth: 1, + }, + pickerBtnPrimary: { + borderWidth: 0, + }, + pickerBtnText: { + fontSize: 16, + fontWeight: '600', + }, +}); \ No newline at end of file diff --git a/app/medications/manage-medications.tsx b/app/medications/manage-medications.tsx index 28b4ac4..f346db2 100644 --- a/app/medications/manage-medications.tsx +++ b/app/medications/manage-medications.tsx @@ -46,7 +46,7 @@ const FILTER_CONFIG: Array<{ key: FilterType; label: string }> = [ { key: 'inactive', label: '已停用' }, ]; -const DEFAULT_IMAGE = require('@/assets/images/icons/icon-healthy-diet.png'); +const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.png'); export default function ManageMedicationsScreen() { const dispatch = useAppDispatch(); diff --git a/app/notification-settings.tsx b/app/notification-settings.tsx new file mode 100644 index 0000000..ef9aab7 --- /dev/null +++ b/app/notification-settings.tsx @@ -0,0 +1,388 @@ +import { ThemedText } from '@/components/ThemedText'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; +import { useNotifications } from '@/hooks/useNotifications'; +import { + getMedicationReminderEnabled, + getNotificationEnabled, + setMedicationReminderEnabled, + setNotificationEnabled +} from '@/utils/userPreferences'; +import { Ionicons } from '@expo/vector-icons'; +import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; +import { LinearGradient } from 'expo-linear-gradient'; +import { router, useFocusEffect } from 'expo-router'; +import React, { useCallback, useState } from 'react'; +import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +export default function NotificationSettingsScreen() { + const insets = useSafeAreaInsets(); + const { pushIfAuthedElseLogin } = useAuthGuard(); + const { requestPermission, sendNotification } = useNotifications(); + const isLgAvailable = isLiquidGlassAvailable(); + + // 通知设置状态 + const [notificationEnabled, setNotificationEnabledState] = useState(false); + const [medicationReminderEnabled, setMedicationReminderEnabledState] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + // 加载通知设置 + const loadNotificationSettings = useCallback(async () => { + try { + const [notification, medicationReminder] = await Promise.all([ + getNotificationEnabled(), + getMedicationReminderEnabled(), + ]); + setNotificationEnabledState(notification); + setMedicationReminderEnabledState(medicationReminder); + } catch (error) { + console.error('加载通知设置失败:', error); + } finally { + setIsLoading(false); + } + }, []); + + // 页面聚焦时加载设置 + useFocusEffect( + useCallback(() => { + loadNotificationSettings(); + }, [loadNotificationSettings]) + ); + + // 处理总通知开关变化 + const handleNotificationToggle = async (value: boolean) => { + if (value) { + try { + // 先检查系统权限 + const status = await requestPermission(); + if (status === 'granted') { + // 系统权限获取成功,保存用户偏好设置 + await setNotificationEnabled(true); + setNotificationEnabledState(true); + + // 发送测试通知 + await sendNotification({ + title: '通知已开启', + body: '您将收到应用通知和提醒', + sound: true, + priority: 'normal', + }); + } else { + // 系统权限被拒绝,不更新用户偏好设置 + Alert.alert( + '权限被拒绝', + '请在系统设置中开启通知权限,然后再尝试开启推送功能', + [ + { text: '取消', style: 'cancel' }, + { text: '去设置', onPress: () => Linking.openSettings() } + ] + ); + } + } catch (error) { + console.error('开启推送通知失败:', error); + Alert.alert('错误', '请求通知权限失败'); + } + } else { + try { + // 关闭推送,保存用户偏好设置 + await setNotificationEnabled(false); + setNotificationEnabledState(false); + // 关闭总开关时,也关闭药品提醒 + await setMedicationReminderEnabled(false); + setMedicationReminderEnabledState(false); + } catch (error) { + console.error('关闭推送通知失败:', error); + Alert.alert('错误', '保存设置失败'); + } + } + }; + + // 处理药品通知提醒开关变化 + const handleMedicationReminderToggle = async (value: boolean) => { + try { + await setMedicationReminderEnabled(value); + setMedicationReminderEnabledState(value); + + if (value) { + // 发送测试通知 + await sendNotification({ + title: '药品提醒已开启', + body: '您将在用药时间收到提醒通知', + sound: true, + priority: 'high', + }); + } + } catch (error) { + console.error('设置药品提醒失败:', error); + Alert.alert('错误', '保存设置失败'); + } + }; + + // 返回按钮 + const BackButton = () => ( + router.back()} + style={styles.backButton} + activeOpacity={0.7} + > + {isLgAvailable ? ( + + + + ) : ( + + + + )} + + ); + + // 开关项组件 + const SwitchItem = ({ + title, + description, + value, + onValueChange, + disabled = false + }: { + title: string; + description: string; + value: boolean; + onValueChange: (value: boolean) => void; + disabled?: boolean; + }) => ( + + + {title} + {description} + + + + ); + + if (isLoading) { + return ( + + + + + 加载中... + + + ); + } + + return ( + + + + {/* 背景渐变 */} + + + {/* 装饰性圆圈 */} + + + + + {/* 头部 */} + + + 通知设置 + + + {/* 通知设置部分 */} + + 通知设置 + + + + + + {/* 药品提醒部分 */} + + 药品提醒 + + + + + + {/* 说明部分 */} + + 说明 + + + • 消息推送是所有通知的总开关{'\n'} + • 药品通知提醒需要在消息推送开启后才能使用{'\n'} + • 您可以在系统设置中管理通知权限{'\n'} + • 关闭消息推送将停止所有应用通知 + + + + + + ); +} + +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, + }, + scrollView: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + fontSize: 16, + color: '#666', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 24, + }, + backButton: { + marginRight: 16, + }, + glassButton: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + fallbackButton: { + backgroundColor: 'rgba(255, 255, 255, 0.9)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.3)', + }, + title: { + fontSize: 28, + fontWeight: 'bold', + color: '#2C3E50', + }, + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: '#2C3E50', + marginBottom: 12, + paddingHorizontal: 4, + }, + card: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 2, + overflow: 'hidden', + }, + switchItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 16, + paddingHorizontal: 16, + }, + switchItemLeft: { + flex: 1, + marginRight: 16, + }, + switchItemTitle: { + fontSize: 16, + fontWeight: '500', + color: '#2C3E50', + marginBottom: 4, + }, + switchItemDescription: { + fontSize: 14, + color: '#6C757D', + lineHeight: 20, + }, + switch: { + transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }], + }, + description: { + fontSize: 14, + color: '#6C757D', + lineHeight: 22, + paddingVertical: 16, + paddingHorizontal: 16, + }, +}); \ No newline at end of file diff --git a/components/ui/InfoCard.tsx b/components/ui/InfoCard.tsx index 206a455..aac2d6a 100644 --- a/components/ui/InfoCard.tsx +++ b/components/ui/InfoCard.tsx @@ -119,14 +119,16 @@ const styles = StyleSheet.create({ flex: 1, borderRadius: 20, padding: 16, - backgroundColor: '#fff', + backgroundColor: '#FFFFFF', gap: 6, shadowColor: '#000', - shadowOpacity: 0.04, - shadowRadius: 8, - shadowOffset: { width: 0, height: 4 }, - elevation: 2, + shadowOpacity: 0.06, + shadowRadius: 12, + shadowOffset: { width: 0, height: 3 }, + elevation: 3, position: 'relative', + borderWidth: 1, + borderColor: 'rgba(0, 0, 0, 0.04)', }, infoCardArrow: { position: 'absolute', diff --git a/constants/Routes.ts b/constants/Routes.ts index 8659d46..f21feb0 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -68,6 +68,12 @@ export const ROUTES = { // 开发者相关路由 DEVELOPER: '/developer', DEVELOPER_LOGS: '/developer/logs', + + // 通知设置路由 + NOTIFICATION_SETTINGS: '/notification-settings', + + // 药品相关路由 + MEDICATION_EDIT_FREQUENCY: '/medications/edit-frequency', } as const; // 路由参数常量 diff --git a/services/medicationNotifications.ts b/services/medicationNotifications.ts new file mode 100644 index 0000000..47d349e --- /dev/null +++ b/services/medicationNotifications.ts @@ -0,0 +1,196 @@ +import type { Medication } from '@/types/medication'; +import { getMedicationReminderEnabled, getNotificationEnabled } from '@/utils/userPreferences'; +import * as Notifications from 'expo-notifications'; +import { notificationService, NotificationTypes } from './notifications'; + +/** + * 药品通知服务 + * 负责管理药品提醒通知的调度和取消 + */ +export class MedicationNotificationService { + private static instance: MedicationNotificationService; + private notificationPrefix = 'medication_'; + + private constructor() {} + + public static getInstance(): MedicationNotificationService { + if (!MedicationNotificationService.instance) { + MedicationNotificationService.instance = new MedicationNotificationService(); + } + return MedicationNotificationService.instance; + } + + /** + * 检查是否可以发送药品通知 + */ + private async canSendMedicationNotifications(): Promise { + try { + // 检查总通知开关 + const notificationEnabled = await getNotificationEnabled(); + if (!notificationEnabled) { + console.log('总通知开关已关闭,跳过药品通知'); + return false; + } + + // 检查药品通知开关 + const medicationReminderEnabled = await getMedicationReminderEnabled(); + if (!medicationReminderEnabled) { + console.log('药品通知开关已关闭,跳过药品通知'); + return false; + } + + // 检查系统权限 + const permissionStatus = await notificationService.getPermissionStatus(); + if (permissionStatus !== 'granted') { + console.log('系统通知权限未授予,跳过药品通知'); + return false; + } + + return true; + } catch (error) { + console.error('检查药品通知权限失败:', error); + return false; + } + } + + /** + * 为药品安排通知 + */ + async scheduleMedicationNotifications(medication: Medication): Promise { + try { + const canSend = await this.canSendMedicationNotifications(); + if (!canSend) { + console.log('药品通知权限不足,跳过安排通知'); + return; + } + + // 先取消该药品的现有通知 + await this.cancelMedicationNotifications(medication.id); + + // 为每个用药时间安排通知 + for (const time of medication.medicationTimes) { + const [hour, minute] = time.split(':').map(Number); + + // 创建通知内容 + const notificationContent = { + title: '用药提醒', + body: `该服用 ${medication.name} 了 (${medication.dosageValue}${medication.dosageUnit})`, + data: { + type: NotificationTypes.MEDICATION_REMINDER, + medicationId: medication.id, + medicationName: medication.name, + dosage: `${medication.dosageValue}${medication.dosageUnit}`, + }, + sound: true, + priority: 'high' as const, + }; + + // 安排每日重复通知 + const notificationId = await notificationService.scheduleCalendarRepeatingNotification( + notificationContent, + { + type: Notifications.SchedulableTriggerInputTypes.DAILY, + hour, + minute, + } + ); + + console.log(`已为药品 ${medication.name} 安排通知,时间: ${time},通知ID: ${notificationId}`); + } + } catch (error) { + console.error('安排药品通知失败:', error); + } + } + + /** + * 取消药品的所有通知 + */ + async cancelMedicationNotifications(medicationId: string): Promise { + try { + // 获取所有已安排的通知 + const allNotifications = await notificationService.getAllScheduledNotifications(); + + // 过滤出该药品的通知并取消 + for (const notification of allNotifications) { + const data = notification.content.data as any; + if (data?.type === NotificationTypes.MEDICATION_REMINDER && + data?.medicationId === medicationId) { + await notificationService.cancelNotification(notification.identifier); + console.log(`已取消药品通知,ID: ${notification.identifier}`); + } + } + } catch (error) { + console.error('取消药品通知失败:', error); + } + } + + /** + * 重新安排所有激活药品的通知 + */ + async rescheduleAllMedicationNotifications(medications: Medication[]): Promise { + try { + // 先取消所有药品通知 + for (const medication of medications) { + await this.cancelMedicationNotifications(medication.id); + } + + // 重新安排激活药品的通知 + const activeMedications = medications.filter(m => m.isActive); + for (const medication of activeMedications) { + await this.scheduleMedicationNotifications(medication); + } + + console.log(`已重新安排 ${activeMedications.length} 个激活药品的通知`); + } catch (error) { + console.error('重新安排药品通知失败:', error); + } + } + + /** + * 发送立即的药品通知(用于测试) + */ + async sendTestMedicationNotification(medication: Medication): Promise { + try { + const canSend = await this.canSendMedicationNotifications(); + if (!canSend) { + throw new Error('药品通知权限不足'); + } + + return await notificationService.sendImmediateNotification({ + title: '用药提醒测试', + body: `这是 ${medication.name} 的测试通知 (${medication.dosageValue}${medication.dosageUnit})`, + data: { + type: NotificationTypes.MEDICATION_REMINDER, + medicationId: medication.id, + medicationName: medication.name, + dosage: `${medication.dosageValue}${medication.dosageUnit}`, + }, + sound: true, + priority: 'high', + }); + } catch (error) { + console.error('发送测试药品通知失败:', error); + throw error; + } + } + + /** + * 获取所有已安排的药品通知 + */ + async getMedicationNotifications(): Promise { + try { + const allNotifications = await notificationService.getAllScheduledNotifications(); + + // 过滤出药品相关的通知 + return allNotifications.filter(notification => + notification.content.data?.type === NotificationTypes.MEDICATION_REMINDER + ); + } catch (error) { + console.error('获取药品通知失败:', error); + return []; + } + } +} + +// 导出单例实例 +export const medicationNotificationService = MedicationNotificationService.getInstance(); diff --git a/services/notifications.ts b/services/notifications.ts index d78ebb1..34dc580 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -204,6 +204,11 @@ export class NotificationService { console.log('用户点击了锻炼完成通知', data); // 跳转到锻炼历史页面 router.push('/workout/history' as any); + } else if (data?.type === NotificationTypes.MEDICATION_REMINDER) { + // 处理药品提醒通知 + console.log('用户点击了药品提醒通知', data); + // 跳转到药品页面 + router.push('/(tabs)/medications' as any); } } @@ -538,6 +543,7 @@ export const NotificationTypes = { WORKOUT_COMPLETION: 'workout_completion', FASTING_START: 'fasting_start', FASTING_END: 'fasting_end', + MEDICATION_REMINDER: 'medication_reminder', } as const; // 便捷方法 @@ -574,3 +580,22 @@ export const sendMoodCheckinReminder = (title: string, body: string, date?: Date return notificationService.sendImmediateNotification(notification); } }; + +export const sendMedicationReminder = (title: string, body: string, medicationId?: string, date?: Date) => { + const notification: NotificationData = { + title, + body, + data: { + type: NotificationTypes.MEDICATION_REMINDER, + medicationId: medicationId || '' + }, + sound: true, + priority: 'high', + }; + + if (date) { + return notificationService.scheduleNotificationAtDate(notification, date); + } else { + return notificationService.sendImmediateNotification(notification); + } +}; diff --git a/utils/userPreferences.ts b/utils/userPreferences.ts index b107229..4d0483a 100644 --- a/utils/userPreferences.ts +++ b/utils/userPreferences.ts @@ -11,6 +11,7 @@ const PREFERENCES_KEYS = { 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', + MEDICATION_REMINDER_ENABLED: 'user_preference_medication_reminder_enabled', } as const; // 用户偏好设置接口 @@ -24,6 +25,7 @@ export interface UserPreferences { waterReminderStartTime: string; // 格式: "08:00" waterReminderEndTime: string; // 格式: "22:00" waterReminderInterval: number; // 分钟 + medicationReminderEnabled: boolean; } // 默认的用户偏好设置 @@ -37,6 +39,7 @@ const DEFAULT_PREFERENCES: UserPreferences = { waterReminderStartTime: '08:00', // 默认开始时间早上8点 waterReminderEndTime: '22:00', // 默认结束时间晚上10点 waterReminderInterval: 60, // 默认提醒间隔60分钟 + medicationReminderEnabled: true, // 默认开启药品提醒 }; /** @@ -53,6 +56,7 @@ export const getUserPreferences = async (): Promise => { 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); + const medicationReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED); return { quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount, @@ -64,6 +68,7 @@ export const getUserPreferences = async (): Promise => { waterReminderStartTime: waterReminderStartTime || DEFAULT_PREFERENCES.waterReminderStartTime, waterReminderEndTime: waterReminderEndTime || DEFAULT_PREFERENCES.waterReminderEndTime, waterReminderInterval: waterReminderInterval ? parseInt(waterReminderInterval, 10) : DEFAULT_PREFERENCES.waterReminderInterval, + medicationReminderEnabled: medicationReminderEnabled !== null ? medicationReminderEnabled === 'true' : DEFAULT_PREFERENCES.medicationReminderEnabled, }; } catch (error) { console.error('获取用户偏好设置失败:', error); @@ -375,8 +380,35 @@ export const resetUserPreferences = async (): Promise => { 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); + await AsyncStorage.removeItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED); } catch (error) { console.error('重置用户偏好设置失败:', error); throw error; } +}; + +/** + * 设置药品提醒开关 + * @param enabled 是否开启药品提醒 + */ +export const setMedicationReminderEnabled = async (enabled: boolean): Promise => { + try { + await AsyncStorage.setItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED, enabled.toString()); + } catch (error) { + console.error('设置药品提醒开关失败:', error); + throw error; + } +}; + +/** + * 获取药品提醒开关状态 + */ +export const getMedicationReminderEnabled = async (): Promise => { + try { + const enabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED); + return enabled !== null ? enabled === 'true' : DEFAULT_PREFERENCES.medicationReminderEnabled; + } catch (error) { + console.error('获取药品提醒开关状态失败:', error); + return DEFAULT_PREFERENCES.medicationReminderEnabled; + } }; \ No newline at end of file