feat(medications): 重构药品通知系统并添加独立设置页面
- 创建药品通知服务模块,统一管理药品提醒通知的调度和取消 - 新增独立的通知设置页面,支持总开关和药品提醒开关分离控制 - 重构药品详情页面,移除频率编辑功能到独立页面 - 优化药品添加流程,支持拍照和相册选择图片 - 改进通知权限检查和错误处理机制 - 更新用户偏好设置,添加药品提醒开关配置
This commit is contained in:
@@ -5,6 +5,7 @@ import { IconSymbol } from '@/components/ui/IconSymbol';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { medicationNotificationService } from '@/services/medicationNotifications';
|
||||||
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice';
|
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice';
|
||||||
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
@@ -67,8 +68,23 @@ export default function MedicationsScreen() {
|
|||||||
// 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据
|
// 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
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])
|
}, [dispatch, selectedKey])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -78,8 +94,6 @@ export default function MedicationsScreen() {
|
|||||||
|
|
||||||
// 为每个药物添加默认图片(如果没有图片)
|
// 为每个药物添加默认图片(如果没有图片)
|
||||||
const medicationsWithImages = useMemo(() => {
|
const medicationsWithImages = useMemo(() => {
|
||||||
console.log('medicationsForDay', medicationsForDay);
|
|
||||||
|
|
||||||
return medicationsForDay.map((med: any) => ({
|
return medicationsForDay.map((med: any) => ({
|
||||||
...med,
|
...med,
|
||||||
image: med.image || require('@/assets/images/medicine/image-medicine.png'), // 默认使用瓶子图标
|
image: med.image || require('@/assets/images/medicine/image-medicine.png'), // 默认使用瓶子图标
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
|
|||||||
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
||||||
import { getItem, setItem } from '@/utils/kvStore';
|
import { getItem, setItem } from '@/utils/kvStore';
|
||||||
import { log } from '@/utils/logger';
|
import { log } from '@/utils/logger';
|
||||||
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -18,7 +17,7 @@ import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
|||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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';
|
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';
|
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,
|
sendNotification,
|
||||||
} = useNotifications();
|
} = useNotifications();
|
||||||
|
|
||||||
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
// 移除 notificationEnabled 状态,因为现在在通知设置页面中管理
|
||||||
|
|
||||||
// 开发者模式相关状态
|
// 开发者模式相关状态
|
||||||
const [showDeveloperSection, setShowDeveloperSection] = useState(false);
|
const [showDeveloperSection, setShowDeveloperSection] = useState(false);
|
||||||
@@ -67,22 +66,13 @@ export default function PersonalScreen() {
|
|||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
dispatch(fetchMyProfile());
|
dispatch(fetchMyProfile());
|
||||||
dispatch(fetchActivityHistory());
|
dispatch(fetchActivityHistory());
|
||||||
// 加载用户推送偏好设置
|
// 不再需要在这里加载推送偏好设置,因为已移到通知设置页面
|
||||||
loadNotificationPreference();
|
|
||||||
// 加载开发者模式状态
|
// 加载开发者模式状态
|
||||||
loadDeveloperModeState();
|
loadDeveloperModeState();
|
||||||
}, [dispatch])
|
}, [dispatch])
|
||||||
);
|
);
|
||||||
|
|
||||||
// 加载用户推送偏好设置
|
// 移除 loadNotificationPreference 函数,因为已移到通知设置页面
|
||||||
const loadNotificationPreference = async () => {
|
|
||||||
try {
|
|
||||||
const enabled = await getNotificationEnabled();
|
|
||||||
setNotificationEnabled(enabled);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载推送偏好设置失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载开发者模式状态
|
// 加载开发者模式状态
|
||||||
const loadDeveloperModeState = async () => {
|
const loadDeveloperModeState = async () => {
|
||||||
@@ -127,9 +117,8 @@ export default function PersonalScreen() {
|
|||||||
// 显示名称
|
// 显示名称
|
||||||
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
||||||
|
|
||||||
// 初始化时加载推送偏好设置和开发者模式状态
|
// 初始化时只加载开发者模式状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadNotificationPreference();
|
|
||||||
loadDeveloperModeState();
|
loadDeveloperModeState();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -160,50 +149,7 @@ export default function PersonalScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理通知开关变化
|
// 移除 handleNotificationToggle 函数,因为已移到通知设置页面
|
||||||
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('错误', '保存设置失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 用户信息头部
|
// 用户信息头部
|
||||||
const UserHeader = () => (
|
const UserHeader = () => (
|
||||||
@@ -412,10 +358,8 @@ export default function PersonalScreen() {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
icon: 'notifications-outline' as const,
|
icon: 'notifications-outline' as const,
|
||||||
title: '消息推送',
|
title: '通知设置',
|
||||||
type: 'switch' as const,
|
onPress: () => pushIfAuthedElseLogin(ROUTES.NOTIFICATION_SETTINGS),
|
||||||
switchValue: notificationEnabled,
|
|
||||||
onSwitchChange: handleNotificationToggle,
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import InfoCard from '@/components/ui/InfoCard';
|
import InfoCard from '@/components/ui/InfoCard';
|
||||||
import { Colors } from '@/constants/Colors';
|
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 { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { medicationNotificationService } from '@/services/medicationNotifications';
|
||||||
import { getMedicationById, getMedicationRecords } from '@/services/medications';
|
import { getMedicationById, getMedicationRecords } from '@/services/medications';
|
||||||
import {
|
import {
|
||||||
deleteMedicationAction,
|
deleteMedicationAction,
|
||||||
@@ -13,9 +15,8 @@ import {
|
|||||||
selectMedications,
|
selectMedications,
|
||||||
updateMedicationAction,
|
updateMedicationAction,
|
||||||
} from '@/store/medicationsSlice';
|
} 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 { Ionicons } from '@expo/vector-icons';
|
||||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
|
||||||
import { Picker } from '@react-native-picker/picker';
|
import { Picker } from '@react-native-picker/picker';
|
||||||
import Voice from '@react-native-voice/voice';
|
import Voice from '@react-native-voice/voice';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -96,69 +97,6 @@ export default function MedicationDetailScreen() {
|
|||||||
const [formPicker, setFormPicker] = useState<MedicationForm>(
|
const [formPicker, setFormPicker] = useState<MedicationForm>(
|
||||||
medicationFromStore?.form ?? 'capsule'
|
medicationFromStore?.form ?? 'capsule'
|
||||||
);
|
);
|
||||||
|
|
||||||
// 频率选择相关状态
|
|
||||||
const [frequencyPickerVisible, setFrequencyPickerVisible] = useState(false);
|
|
||||||
const [repeatPatternPicker, setRepeatPatternPicker] = useState<RepeatPattern>(
|
|
||||||
medicationFromStore?.repeatPattern ?? 'daily'
|
|
||||||
);
|
|
||||||
const [timesPerDayPicker, setTimesPerDayPicker] = useState(
|
|
||||||
medicationFromStore?.timesPerDay ?? 1
|
|
||||||
);
|
|
||||||
|
|
||||||
// 提醒时间相关状态
|
|
||||||
const [timePickerVisible, setTimePickerVisible] = useState(false);
|
|
||||||
const [timePickerDate, setTimePickerDate] = useState<Date>(new Date());
|
|
||||||
const [editingTimeIndex, setEditingTimeIndex] = useState<number | null>(null);
|
|
||||||
const [medicationTimesPicker, setMedicationTimesPicker] = useState<string[]>(
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (!medicationFromStore) {
|
if (!medicationFromStore) {
|
||||||
@@ -174,35 +112,13 @@ export default function MedicationDetailScreen() {
|
|||||||
}, [medicationFromStore]);
|
}, [medicationFromStore]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 同步剂量选择器、剂型选择器、频率选择器和时间选择器的默认值
|
// 同步剂量选择器和剂型选择器的默认值
|
||||||
if (medication) {
|
if (medication) {
|
||||||
setDosageValuePicker(medication.dosageValue);
|
setDosageValuePicker(medication.dosageValue);
|
||||||
setDosageUnitPicker(medication.dosageUnit);
|
setDosageUnitPicker(medication.dosageUnit);
|
||||||
setFormPicker(medication.form);
|
setFormPicker(medication.form);
|
||||||
setRepeatPatternPicker(medication.repeatPattern);
|
|
||||||
setTimesPerDayPicker(medication.timesPerDay);
|
|
||||||
setMedicationTimesPicker(medication.medicationTimes || []);
|
|
||||||
}
|
}
|
||||||
}, [medication?.dosageValue, medication?.dosageUnit, medication?.form, medication?.repeatPattern, medication?.timesPerDay, medication?.medicationTimes]);
|
}, [medication?.dosageValue, medication?.dosageUnit, medication?.form]);
|
||||||
|
|
||||||
// 根据 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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNoteDraft(medication?.note ?? '');
|
setNoteDraft(medication?.note ?? '');
|
||||||
@@ -434,6 +350,20 @@ export default function MedicationDetailScreen() {
|
|||||||
})
|
})
|
||||||
).unwrap();
|
).unwrap();
|
||||||
setMedication(updated);
|
setMedication(updated);
|
||||||
|
|
||||||
|
// 重新安排药品通知
|
||||||
|
try {
|
||||||
|
if (nextValue) {
|
||||||
|
// 如果激活了药品,安排通知
|
||||||
|
await medicationNotificationService.scheduleMedicationNotifications(updated);
|
||||||
|
} else {
|
||||||
|
// 如果停用了药品,取消通知
|
||||||
|
await medicationNotificationService.cancelMedicationNotifications(updated.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MEDICATION] 处理药品通知失败:', error);
|
||||||
|
// 不影响药品状态切换的成功流程,只记录错误
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('切换药品状态失败', err);
|
console.error('切换药品状态失败', err);
|
||||||
Alert.alert('操作失败', '切换提醒状态时出现问题,请稍后重试。');
|
Alert.alert('操作失败', '切换提醒状态时出现问题,请稍后重试。');
|
||||||
@@ -509,6 +439,15 @@ export default function MedicationDetailScreen() {
|
|||||||
try {
|
try {
|
||||||
setDeleteLoading(true);
|
setDeleteLoading(true);
|
||||||
setDeleteSheetVisible(false); // 立即关闭确认对话框
|
setDeleteSheetVisible(false); // 立即关闭确认对话框
|
||||||
|
|
||||||
|
// 先取消该药品的通知
|
||||||
|
try {
|
||||||
|
await medicationNotificationService.cancelMedicationNotifications(medication.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MEDICATION] 取消药品通知失败:', error);
|
||||||
|
// 不影响药品删除的成功流程,只记录错误
|
||||||
|
}
|
||||||
|
|
||||||
await dispatch(deleteMedicationAction(medication.id)).unwrap();
|
await dispatch(deleteMedicationAction(medication.id)).unwrap();
|
||||||
console.log('[MEDICATION_DETAIL] Delete operation successful, navigating back');
|
console.log('[MEDICATION_DETAIL] Delete operation successful, navigating back');
|
||||||
router.back();
|
router.back();
|
||||||
@@ -546,8 +485,18 @@ export default function MedicationDetailScreen() {
|
|||||||
|
|
||||||
const handleFrequencyPress = useCallback(() => {
|
const handleFrequencyPress = useCallback(() => {
|
||||||
if (!medication) return;
|
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 () => {
|
const confirmDosagePicker = useCallback(async () => {
|
||||||
if (!medication || updatePending) return;
|
if (!medication || updatePending) return;
|
||||||
@@ -569,6 +518,14 @@ export default function MedicationDetailScreen() {
|
|||||||
})
|
})
|
||||||
).unwrap();
|
).unwrap();
|
||||||
setMedication(updated);
|
setMedication(updated);
|
||||||
|
|
||||||
|
// 重新安排药品通知
|
||||||
|
try {
|
||||||
|
await medicationNotificationService.scheduleMedicationNotifications(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MEDICATION] 安排药品通知失败:', error);
|
||||||
|
// 不影响药品更新的成功流程,只记录错误
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('更新剂量失败', err);
|
console.error('更新剂量失败', err);
|
||||||
Alert.alert('更新失败', '更新剂量时出现问题,请稍后重试。');
|
Alert.alert('更新失败', '更新剂量时出现问题,请稍后重试。');
|
||||||
@@ -596,6 +553,14 @@ export default function MedicationDetailScreen() {
|
|||||||
})
|
})
|
||||||
).unwrap();
|
).unwrap();
|
||||||
setMedication(updated);
|
setMedication(updated);
|
||||||
|
|
||||||
|
// 重新安排药品通知
|
||||||
|
try {
|
||||||
|
await medicationNotificationService.scheduleMedicationNotifications(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MEDICATION] 安排药品通知失败:', error);
|
||||||
|
// 不影响药品更新的成功流程,只记录错误
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('更新剂型失败', err);
|
console.error('更新剂型失败', err);
|
||||||
Alert.alert('更新失败', '更新剂型时出现问题,请稍后重试。');
|
Alert.alert('更新失败', '更新剂型时出现问题,请稍后重试。');
|
||||||
@@ -603,110 +568,6 @@ export default function MedicationDetailScreen() {
|
|||||||
setUpdatePending(false);
|
setUpdatePending(false);
|
||||||
}
|
}
|
||||||
}, [dispatch, formPicker, medication, updatePending]);
|
}, [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) {
|
if (!medicationId) {
|
||||||
return (
|
return (
|
||||||
@@ -889,7 +750,6 @@ export default function MedicationDetailScreen() {
|
|||||||
styles.footerBar,
|
styles.footerBar,
|
||||||
{
|
{
|
||||||
paddingBottom: Math.max(insets.bottom, 18),
|
paddingBottom: Math.max(insets.bottom, 18),
|
||||||
backgroundColor: colors.pageBackgroundEmphasis,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -1138,178 +998,6 @@ export default function MedicationDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
|
||||||
visible={frequencyPickerVisible}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={() => setFrequencyPickerVisible(false)}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={styles.pickerBackdrop}
|
|
||||||
onPress={() => setFrequencyPickerVisible(false)}
|
|
||||||
/>
|
|
||||||
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
|
|
||||||
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
|
|
||||||
选择服药频率
|
|
||||||
</ThemedText>
|
|
||||||
<View style={styles.pickerRow}>
|
|
||||||
<View style={styles.pickerColumn}>
|
|
||||||
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
|
|
||||||
重复模式
|
|
||||||
</ThemedText>
|
|
||||||
<Picker
|
|
||||||
selectedValue={repeatPatternPicker}
|
|
||||||
onValueChange={(value) => setRepeatPatternPicker(value as RepeatPattern)}
|
|
||||||
itemStyle={styles.pickerItem}
|
|
||||||
style={styles.picker}
|
|
||||||
>
|
|
||||||
<Picker.Item label="每日" value="daily" />
|
|
||||||
{/* <Picker.Item label="每周" value="weekly" />
|
|
||||||
<Picker.Item label="自定义" value="custom" /> */}
|
|
||||||
</Picker>
|
|
||||||
</View>
|
|
||||||
<View style={styles.pickerColumn}>
|
|
||||||
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
|
|
||||||
每日次数
|
|
||||||
</ThemedText>
|
|
||||||
<Picker
|
|
||||||
selectedValue={timesPerDayPicker}
|
|
||||||
onValueChange={(value) => setTimesPerDayPicker(Number(value))}
|
|
||||||
itemStyle={styles.pickerItem}
|
|
||||||
style={styles.picker}
|
|
||||||
>
|
|
||||||
{TIMES_PER_DAY_OPTIONS.map((times) => (
|
|
||||||
<Picker.Item
|
|
||||||
key={times}
|
|
||||||
label={`${times} 次`}
|
|
||||||
value={times}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Picker>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 提醒时间列表 */}
|
|
||||||
<View style={styles.timeListSection}>
|
|
||||||
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary, marginBottom: 8 }]}>
|
|
||||||
每日提醒时间
|
|
||||||
</ThemedText>
|
|
||||||
<ScrollView style={styles.timeListScroll} showsVerticalScrollIndicator={false}>
|
|
||||||
{medicationTimesPicker.map((time, index) => (
|
|
||||||
<View
|
|
||||||
key={`${time}-${index}`}
|
|
||||||
style={[
|
|
||||||
styles.timeItemInPicker,
|
|
||||||
{
|
|
||||||
borderColor: `${colors.border}80`,
|
|
||||||
backgroundColor: colors.pageBackgroundEmphasis,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TouchableOpacity style={styles.timeValue} onPress={() => openTimePicker(index)}>
|
|
||||||
<Ionicons name="time" size={16} color={colors.primary} />
|
|
||||||
<ThemedText style={[styles.timeTextSmall, { color: colors.text }]}>{time}</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Pressable onPress={() => removeTime(index)} disabled={medicationTimesPicker.length === 1} hitSlop={12}>
|
|
||||||
<Ionicons
|
|
||||||
name="close-circle"
|
|
||||||
size={16}
|
|
||||||
color={medicationTimesPicker.length === 1 ? `${colors.border}80` : colors.textSecondary}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.addTimeButtonSmall, { borderColor: colors.primary }]}
|
|
||||||
onPress={addTime}
|
|
||||||
>
|
|
||||||
<Ionicons name="add" size={14} color={colors.primary} />
|
|
||||||
<ThemedText style={[styles.addTimeLabelSmall, { color: colors.primary }]}>添加时间</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.pickerActions}>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setFrequencyPickerVisible(false)}
|
|
||||||
style={[styles.pickerBtn, { borderColor: colors.border }]}
|
|
||||||
>
|
|
||||||
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
|
|
||||||
取消
|
|
||||||
</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={confirmFrequencyPicker}
|
|
||||||
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
|
|
||||||
>
|
|
||||||
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
|
|
||||||
确定
|
|
||||||
</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* 时间选择器 Modal */}
|
|
||||||
<Modal
|
|
||||||
visible={timePickerVisible}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={() => {
|
|
||||||
setTimePickerVisible(false);
|
|
||||||
setEditingTimeIndex(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={styles.pickerBackdrop}
|
|
||||||
onPress={() => {
|
|
||||||
setTimePickerVisible(false);
|
|
||||||
setEditingTimeIndex(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
|
|
||||||
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
|
|
||||||
{editingTimeIndex !== null ? '修改提醒时间' : '添加提醒时间'}
|
|
||||||
</ThemedText>
|
|
||||||
<DateTimePicker
|
|
||||||
value={timePickerDate}
|
|
||||||
mode="time"
|
|
||||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
|
||||||
onChange={(event, date) => {
|
|
||||||
if (Platform.OS === 'ios') {
|
|
||||||
if (date) setTimePickerDate(date);
|
|
||||||
} else {
|
|
||||||
if (event.type === 'set' && date) {
|
|
||||||
confirmTime(date);
|
|
||||||
} else {
|
|
||||||
setTimePickerVisible(false);
|
|
||||||
setEditingTimeIndex(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{Platform.OS === 'ios' && (
|
|
||||||
<View style={styles.pickerActions}>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
setTimePickerVisible(false);
|
|
||||||
setEditingTimeIndex(null);
|
|
||||||
}}
|
|
||||||
style={[styles.pickerBtn, { borderColor: colors.border }]}
|
|
||||||
>
|
|
||||||
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>取消</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => confirmTime(timePickerDate)}
|
|
||||||
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
|
|
||||||
>
|
|
||||||
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>确定</ThemedText>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{medication ? (
|
{medication ? (
|
||||||
<ConfirmationSheet
|
<ConfirmationSheet
|
||||||
visible={deleteSheetVisible}
|
visible={deleteSheetVisible}
|
||||||
@@ -1407,13 +1095,16 @@ const styles = StyleSheet.create({
|
|||||||
padding: 20,
|
padding: 20,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
shadowColor: '#000',
|
shadowColor: '#7a5af8',
|
||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.12,
|
||||||
shadowRadius: 12,
|
shadowRadius: 16,
|
||||||
shadowOffset: { width: 0, height: 8 },
|
shadowOffset: { width: 0, height: 4 },
|
||||||
elevation: 3,
|
elevation: 5,
|
||||||
gap: 12,
|
gap: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(122, 90, 248, 0.08)',
|
||||||
},
|
},
|
||||||
heroInfo: {
|
heroInfo: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -1465,6 +1156,13 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 12,
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
elevation: 3,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.04)',
|
||||||
},
|
},
|
||||||
fullCardLeading: {
|
fullCardLeading: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -1491,6 +1189,13 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
paddingHorizontal: 18,
|
paddingHorizontal: 18,
|
||||||
paddingVertical: 16,
|
paddingVertical: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 12,
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
elevation: 3,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.04)',
|
||||||
},
|
},
|
||||||
noteBody: {
|
noteBody: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -1510,6 +1215,13 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
padding: 18,
|
padding: 18,
|
||||||
gap: 16,
|
gap: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 12,
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
elevation: 3,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.04)',
|
||||||
},
|
},
|
||||||
summaryIcon: {
|
summaryIcon: {
|
||||||
width: 44,
|
width: 44,
|
||||||
@@ -1718,46 +1430,6 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
// 时间列表相关样式
|
|
||||||
timeListSection: {
|
|
||||||
gap: 12,
|
|
||||||
marginTop: 16,
|
|
||||||
},
|
|
||||||
timeListScroll: {
|
|
||||||
maxHeight: 200,
|
|
||||||
},
|
|
||||||
timeItemInPicker: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 10,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
timeValue: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
timeTextSmall: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
addTimeButtonSmall: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 10,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
addTimeLabelSmall: {
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
imagePreviewHint: {
|
imagePreviewHint: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 4,
|
top: 4,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAppDispatch } from '@/hooks/redux';
|
|||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
|
import { medicationNotificationService } from '@/services/medicationNotifications';
|
||||||
import { createMedicationAction, fetchMedicationRecords, fetchMedications } from '@/store/medicationsSlice';
|
import { createMedicationAction, fetchMedicationRecords, fetchMedications } from '@/store/medicationsSlice';
|
||||||
import type { MedicationForm, RepeatPattern } from '@/types/medication';
|
import type { MedicationForm, RepeatPattern } from '@/types/medication';
|
||||||
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
@@ -288,6 +289,16 @@ export default function AddMedicationScreen() {
|
|||||||
const today = dayjs().format('YYYY-MM-DD');
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
await dispatch(fetchMedicationRecords({ date: today }));
|
await dispatch(fetchMedicationRecords({ date: today }));
|
||||||
|
|
||||||
|
// 重新安排药品通知
|
||||||
|
try {
|
||||||
|
// 获取最新的药品列表
|
||||||
|
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
|
||||||
|
await medicationNotificationService.rescheduleAllMedicationNotifications(medications);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MEDICATION] 安排药品通知失败:', error);
|
||||||
|
// 不影响添加药品的成功流程,只记录错误
|
||||||
|
}
|
||||||
|
|
||||||
// 成功提示
|
// 成功提示
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'添加成功',
|
'添加成功',
|
||||||
@@ -357,42 +368,99 @@ export default function AddMedicationScreen() {
|
|||||||
}
|
}
|
||||||
}, [dictationActive, dictationLoading, isDictationSupported]);
|
}, [dictationActive, dictationLoading, isDictationSupported]);
|
||||||
|
|
||||||
const handleTakePhoto = useCallback(async () => {
|
// 处理图片选择(拍照或相册)
|
||||||
try {
|
const handleSelectPhoto = useCallback(() => {
|
||||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
Alert.alert(
|
||||||
if (permission.status !== 'granted') {
|
'选择图片',
|
||||||
Alert.alert('权限不足', '需要相机权限以拍摄药品照片');
|
'请选择图片来源',
|
||||||
return;
|
[
|
||||||
}
|
{
|
||||||
|
text: '拍照',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
|
if (permission.status !== 'granted') {
|
||||||
|
Alert.alert('权限不足', '需要相机权限以拍摄药品照片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await ImagePicker.launchCameraAsync({
|
const result = await ImagePicker.launchCameraAsync({
|
||||||
allowsEditing: true,
|
allowsEditing: true,
|
||||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
quality: 0.9,
|
||||||
quality: 0.9,
|
aspect: [9,16]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.canceled || !result.assets?.length) {
|
if (result.canceled || !result.assets?.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const asset = result.assets[0];
|
const asset = result.assets[0];
|
||||||
setPhotoPreview(asset.uri);
|
setPhotoPreview(asset.uri);
|
||||||
setPhotoUrl(null);
|
setPhotoUrl(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { url } = await upload(
|
const { url } = await upload(
|
||||||
{ uri: asset.uri, name: asset.fileName ?? `medication-${Date.now()}.jpg`, type: asset.mimeType ?? 'image/jpeg' },
|
{ uri: asset.uri, name: asset.fileName ?? `medication-${Date.now()}.jpg`, type: asset.mimeType ?? 'image/jpeg' },
|
||||||
{ prefix: 'images/medications' }
|
{ prefix: 'images/medications' }
|
||||||
);
|
);
|
||||||
setPhotoUrl(url);
|
setPhotoUrl(url);
|
||||||
} catch (uploadError) {
|
} catch (uploadError) {
|
||||||
console.error('[MEDICATION] 图片上传失败', uploadError);
|
console.error('[MEDICATION] 图片上传失败', uploadError);
|
||||||
Alert.alert('上传失败', '图片上传失败,请稍后重试');
|
Alert.alert('上传失败', '图片上传失败,请稍后重试');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MEDICATION] 拍照失败', error);
|
console.error('[MEDICATION] 拍照失败', error);
|
||||||
Alert.alert('拍照失败', '无法打开相机,请稍后再试');
|
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]);
|
}, [upload]);
|
||||||
|
|
||||||
const handleRemovePhoto = useCallback(() => {
|
const handleRemovePhoto = useCallback(() => {
|
||||||
@@ -539,7 +607,7 @@ export default function AddMedicationScreen() {
|
|||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onPress={handleTakePhoto}
|
onPress={handleSelectPhoto}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
>
|
>
|
||||||
{photoPreview ? (
|
{photoPreview ? (
|
||||||
@@ -548,7 +616,7 @@ export default function AddMedicationScreen() {
|
|||||||
<View style={styles.photoOverlay}>
|
<View style={styles.photoOverlay}>
|
||||||
<Ionicons name="camera" size={18} color="#fff" />
|
<Ionicons name="camera" size={18} color="#fff" />
|
||||||
<ThemedText style={styles.photoOverlayText}>
|
<ThemedText style={styles.photoOverlayText}>
|
||||||
{uploading ? '上传中…' : '重新拍摄'}
|
{uploading ? '上传中…' : '重新选择'}
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
<Pressable style={styles.photoRemoveBtn} onPress={handleRemovePhoto} hitSlop={12}>
|
<Pressable style={styles.photoRemoveBtn} onPress={handleRemovePhoto} hitSlop={12}>
|
||||||
@@ -560,8 +628,8 @@ export default function AddMedicationScreen() {
|
|||||||
<View style={[styles.photoIconBadge, { backgroundColor: `${colors.primary}12` }]}>
|
<View style={[styles.photoIconBadge, { backgroundColor: `${colors.primary}12` }]}>
|
||||||
<Ionicons name="camera" size={22} color={colors.primary} />
|
<Ionicons name="camera" size={22} color={colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
<ThemedText style={[styles.photoTitle, { color: colors.text }]}>拍照上传药品图片</ThemedText>
|
<ThemedText style={[styles.photoTitle, { color: colors.text }]}>上传药品图片</ThemedText>
|
||||||
<ThemedText style={[styles.photoSubtitle, { color: colors.textMuted }]}>辅助识别药品包装,更易区分</ThemedText>
|
<ThemedText style={[styles.photoSubtitle, { color: colors.textMuted }]}>拍照或从相册选择,辅助识别药品包装</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{uploading && (
|
{uploading && (
|
||||||
|
|||||||
680
app/medications/edit-frequency.tsx
Normal file
680
app/medications/edit-frequency.tsx
Normal file
@@ -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<RepeatPattern>(initialRepeatPattern);
|
||||||
|
const [timesPerDay, setTimesPerDay] = useState(initialTimesPerDay);
|
||||||
|
const [medicationTimes, setMedicationTimes] = useState<string[]>(initialTimes);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 时间选择器相关状态
|
||||||
|
const [timePickerVisible, setTimePickerVisible] = useState(false);
|
||||||
|
const [timePickerDate, setTimePickerDate] = useState<Date>(new Date());
|
||||||
|
const [editingTimeIndex, setEditingTimeIndex] = useState<number | null>(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 (
|
||||||
|
<View style={[styles.container, { backgroundColor: colors.pageBackgroundEmphasis }]}>
|
||||||
|
<HeaderBar title="编辑服药频率" variant="minimal" />
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<ThemedText style={styles.emptyText}>缺少必要参数</ThemedText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colors.pageBackgroundEmphasis }]}>
|
||||||
|
<HeaderBar
|
||||||
|
title="编辑服药频率"
|
||||||
|
variant="minimal"
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.content,
|
||||||
|
{
|
||||||
|
paddingTop: insets.top + 72,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 16) + 120,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* 药品名称提示 */}
|
||||||
|
{medicationName && (
|
||||||
|
<View style={[styles.medicationNameCard, { backgroundColor: colors.surface }]}>
|
||||||
|
<Ionicons name="medical" size={20} color={colors.primary} />
|
||||||
|
<ThemedText style={[styles.medicationNameText, { color: colors.text }]}>
|
||||||
|
{medicationName}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 频率选择 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<ThemedText style={[styles.sectionTitle, { color: colors.text }]}>
|
||||||
|
服药频率
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText style={[styles.sectionDescription, { color: colors.textSecondary }]}>
|
||||||
|
设置每日服药次数
|
||||||
|
</ThemedText>
|
||||||
|
|
||||||
|
<View style={styles.pickerRow}>
|
||||||
|
<View style={styles.pickerColumn}>
|
||||||
|
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
|
||||||
|
重复模式
|
||||||
|
</ThemedText>
|
||||||
|
<Picker
|
||||||
|
selectedValue={repeatPattern}
|
||||||
|
onValueChange={(value) => setRepeatPattern(value as RepeatPattern)}
|
||||||
|
itemStyle={styles.pickerItem}
|
||||||
|
style={styles.picker}
|
||||||
|
>
|
||||||
|
<Picker.Item label="每日" value="daily" />
|
||||||
|
{/* <Picker.Item label="每周" value="weekly" />
|
||||||
|
<Picker.Item label="自定义" value="custom" /> */}
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
<View style={styles.pickerColumn}>
|
||||||
|
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
|
||||||
|
每日次数
|
||||||
|
</ThemedText>
|
||||||
|
<Picker
|
||||||
|
selectedValue={timesPerDay}
|
||||||
|
onValueChange={(value) => setTimesPerDay(Number(value))}
|
||||||
|
itemStyle={styles.pickerItem}
|
||||||
|
style={styles.picker}
|
||||||
|
>
|
||||||
|
{TIMES_PER_DAY_OPTIONS.map((times) => (
|
||||||
|
<Picker.Item
|
||||||
|
key={times}
|
||||||
|
label={`${times} 次`}
|
||||||
|
value={times}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.frequencySummary, { backgroundColor: colors.surface }]}>
|
||||||
|
<Ionicons name="repeat" size={18} color={colors.primary} />
|
||||||
|
<ThemedText style={[styles.frequencySummaryText, { color: colors.text }]}>
|
||||||
|
{frequencyLabel}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 提醒时间列表 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<ThemedText style={[styles.sectionTitle, { color: colors.text }]}>
|
||||||
|
每日提醒时间
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText style={[styles.sectionDescription, { color: colors.textSecondary }]}>
|
||||||
|
添加并管理每天的提醒时间
|
||||||
|
</ThemedText>
|
||||||
|
|
||||||
|
<View style={styles.timeList}>
|
||||||
|
{medicationTimes.map((time, index) => (
|
||||||
|
<View
|
||||||
|
key={`${time}-${index}`}
|
||||||
|
style={[
|
||||||
|
styles.timeItem,
|
||||||
|
{
|
||||||
|
borderColor: `${colors.border}80`,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TouchableOpacity style={styles.timeValue} onPress={() => openTimePicker(index)}>
|
||||||
|
<Ionicons name="time" size={20} color={colors.primary} />
|
||||||
|
<ThemedText style={[styles.timeText, { color: colors.text }]}>{time}</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => removeTime(index)}
|
||||||
|
disabled={medicationTimes.length === 1}
|
||||||
|
hitSlop={12}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="close-circle"
|
||||||
|
size={20}
|
||||||
|
color={medicationTimes.length === 1 ? `${colors.border}80` : colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.addTimeButton, { borderColor: colors.primary }]}
|
||||||
|
onPress={addTime}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={18} color={colors.primary} />
|
||||||
|
<ThemedText style={[styles.addTimeLabel, { color: colors.primary }]}>
|
||||||
|
添加时间
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 底部保存按钮 */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.footerBar,
|
||||||
|
{
|
||||||
|
paddingBottom: Math.max(insets.bottom, 18),
|
||||||
|
backgroundColor: colors.pageBackgroundEmphasis,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{isLiquidGlassAvailable() ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.saveButton}
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
tintColor={`rgba(122, 90, 248, 0.8)`}
|
||||||
|
isInteractive={!saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="checkmark-circle" size={20} color="#fff" />
|
||||||
|
<ThemedText style={styles.saveButtonText}>保存修改</ThemedText>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.saveButton, styles.fallbackSaveButton]}>
|
||||||
|
{saving ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="checkmark-circle" size={20} color="#fff" />
|
||||||
|
<ThemedText style={styles.saveButtonText}>保存修改</ThemedText>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 时间选择器 Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={timePickerVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => {
|
||||||
|
setTimePickerVisible(false);
|
||||||
|
setEditingTimeIndex(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.pickerBackdrop}
|
||||||
|
onPress={() => {
|
||||||
|
setTimePickerVisible(false);
|
||||||
|
setEditingTimeIndex(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
|
||||||
|
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
|
||||||
|
{editingTimeIndex !== null ? '修改提醒时间' : '添加提醒时间'}
|
||||||
|
</ThemedText>
|
||||||
|
<DateTimePicker
|
||||||
|
value={timePickerDate}
|
||||||
|
mode="time"
|
||||||
|
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||||
|
onChange={(event, date) => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
if (date) setTimePickerDate(date);
|
||||||
|
} else {
|
||||||
|
if (event.type === 'set' && date) {
|
||||||
|
confirmTime(date);
|
||||||
|
} else {
|
||||||
|
setTimePickerVisible(false);
|
||||||
|
setEditingTimeIndex(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Platform.OS === 'ios' && (
|
||||||
|
<View style={styles.pickerActions}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setTimePickerVisible(false);
|
||||||
|
setEditingTimeIndex(null);
|
||||||
|
}}
|
||||||
|
style={[styles.pickerBtn, { borderColor: colors.border }]}
|
||||||
|
>
|
||||||
|
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
|
||||||
|
取消
|
||||||
|
</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => confirmTime(timePickerDate)}
|
||||||
|
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
|
||||||
|
>
|
||||||
|
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
|
||||||
|
确定
|
||||||
|
</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -46,7 +46,7 @@ const FILTER_CONFIG: Array<{ key: FilterType; label: string }> = [
|
|||||||
{ key: 'inactive', label: '已停用' },
|
{ 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() {
|
export default function ManageMedicationsScreen() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|||||||
388
app/notification-settings.tsx
Normal file
388
app/notification-settings.tsx
Normal file
@@ -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 = () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={styles.backButton}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{isLgAvailable ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.glassButton}
|
||||||
|
glassEffectStyle="clear"
|
||||||
|
tintColor="rgba(255, 255, 255, 0.3)"
|
||||||
|
isInteractive={true}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="#333" />
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.glassButton, styles.fallbackButton]}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="#333" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 开关项组件
|
||||||
|
const SwitchItem = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
disabled = false
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (value: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => (
|
||||||
|
<View style={styles.switchItem}>
|
||||||
|
<View style={styles.switchItemLeft}>
|
||||||
|
<Text style={styles.switchItemTitle}>{title}</Text>
|
||||||
|
<Text style={styles.switchItemDescription}>{description}</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={value}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
disabled={disabled}
|
||||||
|
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
|
||||||
|
thumbColor="#FFFFFF"
|
||||||
|
style={styles.switch}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loadingText}>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||||
|
|
||||||
|
{/* 背景渐变 */}
|
||||||
|
<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} />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 20,
|
||||||
|
paddingBottom: insets.bottom + 20,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* 头部 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<BackButton />
|
||||||
|
<ThemedText style={styles.title}>通知设置</ThemedText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 通知设置部分 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>通知设置</Text>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<SwitchItem
|
||||||
|
title="消息推送"
|
||||||
|
description="开启后将接收应用通知"
|
||||||
|
value={notificationEnabled}
|
||||||
|
onValueChange={handleNotificationToggle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 药品提醒部分 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>药品提醒</Text>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<SwitchItem
|
||||||
|
title="药品通知提醒"
|
||||||
|
description="在用药时间接收提醒通知"
|
||||||
|
value={medicationReminderEnabled}
|
||||||
|
onValueChange={handleMedicationReminderToggle}
|
||||||
|
disabled={!notificationEnabled}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 说明部分 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>说明</Text>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
• 消息推送是所有通知的总开关{'\n'}
|
||||||
|
• 药品通知提醒需要在消息推送开启后才能使用{'\n'}
|
||||||
|
• 您可以在系统设置中管理通知权限{'\n'}
|
||||||
|
• 关闭消息推送将停止所有应用通知
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</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,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -119,14 +119,16 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#FFFFFF',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOpacity: 0.04,
|
shadowOpacity: 0.06,
|
||||||
shadowRadius: 8,
|
shadowRadius: 12,
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 3 },
|
||||||
elevation: 2,
|
elevation: 3,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.04)',
|
||||||
},
|
},
|
||||||
infoCardArrow: {
|
infoCardArrow: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ export const ROUTES = {
|
|||||||
// 开发者相关路由
|
// 开发者相关路由
|
||||||
DEVELOPER: '/developer',
|
DEVELOPER: '/developer',
|
||||||
DEVELOPER_LOGS: '/developer/logs',
|
DEVELOPER_LOGS: '/developer/logs',
|
||||||
|
|
||||||
|
// 通知设置路由
|
||||||
|
NOTIFICATION_SETTINGS: '/notification-settings',
|
||||||
|
|
||||||
|
// 药品相关路由
|
||||||
|
MEDICATION_EDIT_FREQUENCY: '/medications/edit-frequency',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 路由参数常量
|
// 路由参数常量
|
||||||
|
|||||||
196
services/medicationNotifications.ts
Normal file
196
services/medicationNotifications.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<Notifications.NotificationRequest[]> {
|
||||||
|
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();
|
||||||
@@ -204,6 +204,11 @@ export class NotificationService {
|
|||||||
console.log('用户点击了锻炼完成通知', data);
|
console.log('用户点击了锻炼完成通知', data);
|
||||||
// 跳转到锻炼历史页面
|
// 跳转到锻炼历史页面
|
||||||
router.push('/workout/history' as any);
|
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',
|
WORKOUT_COMPLETION: 'workout_completion',
|
||||||
FASTING_START: 'fasting_start',
|
FASTING_START: 'fasting_start',
|
||||||
FASTING_END: 'fasting_end',
|
FASTING_END: 'fasting_end',
|
||||||
|
MEDICATION_REMINDER: 'medication_reminder',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 便捷方法
|
// 便捷方法
|
||||||
@@ -574,3 +580,22 @@ export const sendMoodCheckinReminder = (title: string, body: string, date?: Date
|
|||||||
return notificationService.sendImmediateNotification(notification);
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const PREFERENCES_KEYS = {
|
|||||||
WATER_REMINDER_START_TIME: 'user_preference_water_reminder_start_time',
|
WATER_REMINDER_START_TIME: 'user_preference_water_reminder_start_time',
|
||||||
WATER_REMINDER_END_TIME: 'user_preference_water_reminder_end_time',
|
WATER_REMINDER_END_TIME: 'user_preference_water_reminder_end_time',
|
||||||
WATER_REMINDER_INTERVAL: 'user_preference_water_reminder_interval',
|
WATER_REMINDER_INTERVAL: 'user_preference_water_reminder_interval',
|
||||||
|
MEDICATION_REMINDER_ENABLED: 'user_preference_medication_reminder_enabled',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 用户偏好设置接口
|
// 用户偏好设置接口
|
||||||
@@ -24,6 +25,7 @@ export interface UserPreferences {
|
|||||||
waterReminderStartTime: string; // 格式: "08:00"
|
waterReminderStartTime: string; // 格式: "08:00"
|
||||||
waterReminderEndTime: string; // 格式: "22:00"
|
waterReminderEndTime: string; // 格式: "22:00"
|
||||||
waterReminderInterval: number; // 分钟
|
waterReminderInterval: number; // 分钟
|
||||||
|
medicationReminderEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认的用户偏好设置
|
// 默认的用户偏好设置
|
||||||
@@ -37,6 +39,7 @@ const DEFAULT_PREFERENCES: UserPreferences = {
|
|||||||
waterReminderStartTime: '08:00', // 默认开始时间早上8点
|
waterReminderStartTime: '08:00', // 默认开始时间早上8点
|
||||||
waterReminderEndTime: '22:00', // 默认结束时间晚上10点
|
waterReminderEndTime: '22:00', // 默认结束时间晚上10点
|
||||||
waterReminderInterval: 60, // 默认提醒间隔60分钟
|
waterReminderInterval: 60, // 默认提醒间隔60分钟
|
||||||
|
medicationReminderEnabled: true, // 默认开启药品提醒
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +56,7 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
|
|||||||
const waterReminderStartTime = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_START_TIME);
|
const waterReminderStartTime = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_START_TIME);
|
||||||
const waterReminderEndTime = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_END_TIME);
|
const waterReminderEndTime = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_END_TIME);
|
||||||
const waterReminderInterval = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_INTERVAL);
|
const waterReminderInterval = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_INTERVAL);
|
||||||
|
const medicationReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount,
|
quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount,
|
||||||
@@ -64,6 +68,7 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
|
|||||||
waterReminderStartTime: waterReminderStartTime || DEFAULT_PREFERENCES.waterReminderStartTime,
|
waterReminderStartTime: waterReminderStartTime || DEFAULT_PREFERENCES.waterReminderStartTime,
|
||||||
waterReminderEndTime: waterReminderEndTime || DEFAULT_PREFERENCES.waterReminderEndTime,
|
waterReminderEndTime: waterReminderEndTime || DEFAULT_PREFERENCES.waterReminderEndTime,
|
||||||
waterReminderInterval: waterReminderInterval ? parseInt(waterReminderInterval, 10) : DEFAULT_PREFERENCES.waterReminderInterval,
|
waterReminderInterval: waterReminderInterval ? parseInt(waterReminderInterval, 10) : DEFAULT_PREFERENCES.waterReminderInterval,
|
||||||
|
medicationReminderEnabled: medicationReminderEnabled !== null ? medicationReminderEnabled === 'true' : DEFAULT_PREFERENCES.medicationReminderEnabled,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取用户偏好设置失败:', error);
|
console.error('获取用户偏好设置失败:', error);
|
||||||
@@ -375,8 +380,35 @@ export const resetUserPreferences = async (): Promise<void> => {
|
|||||||
await AsyncStorage.removeItem(PREFERENCES_KEYS.WATER_REMINDER_START_TIME);
|
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_END_TIME);
|
||||||
await AsyncStorage.removeItem(PREFERENCES_KEYS.WATER_REMINDER_INTERVAL);
|
await AsyncStorage.removeItem(PREFERENCES_KEYS.WATER_REMINDER_INTERVAL);
|
||||||
|
await AsyncStorage.removeItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('重置用户偏好设置失败:', error);
|
console.error('重置用户偏好设置失败:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置药品提醒开关
|
||||||
|
* @param enabled 是否开启药品提醒
|
||||||
|
*/
|
||||||
|
export const setMedicationReminderEnabled = async (enabled: boolean): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED, enabled.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('设置药品提醒开关失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取药品提醒开关状态
|
||||||
|
*/
|
||||||
|
export const getMedicationReminderEnabled = async (): Promise<boolean> => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user