feat(medications): 重构药品通知系统并添加独立设置页面

- 创建药品通知服务模块,统一管理药品提醒通知的调度和取消
- 新增独立的通知设置页面,支持总开关和药品提醒开关分离控制
- 重构药品详情页面,移除频率编辑功能到独立页面
- 优化药品添加流程,支持拍照和相册选择图片
- 改进通知权限检查和错误处理机制
- 更新用户偏好设置,添加药品提醒开关配置
This commit is contained in:
richarjiang
2025-11-11 16:43:27 +08:00
parent d9975813cb
commit f4ce3d9edf
12 changed files with 1551 additions and 524 deletions

View File

@@ -5,6 +5,7 @@ import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { medicationNotificationService } from '@/services/medicationNotifications';
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice';
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
import { useFocusEffect } from '@react-navigation/native';
@@ -67,8 +68,23 @@ export default function MedicationsScreen() {
// 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据
useFocusEffect(
useCallback(() => {
dispatch(fetchMedications({ isActive: true }));
dispatch(fetchMedicationRecords({ date: selectedKey }));
// 重新安排药品通知并刷新数据
const refreshDataAndRescheduleNotifications = async () => {
try {
// 只获取一次药物数据,然后复用结果
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
// 并行执行获取药物记录和安排通知
await Promise.all([
dispatch(fetchMedicationRecords({ date: selectedKey })),
medicationNotificationService.rescheduleAllMedicationNotifications(medications),
]);
} catch (error) {
console.error('刷新数据或重新安排药品通知失败:', error);
}
};
refreshDataAndRescheduleNotifications();
}, [dispatch, selectedKey])
);
@@ -78,8 +94,6 @@ export default function MedicationsScreen() {
// 为每个药物添加默认图片(如果没有图片)
const medicationsWithImages = useMemo(() => {
console.log('medicationsForDay', medicationsForDay);
return medicationsForDay.map((med: any) => ({
...med,
image: med.image || require('@/assets/images/medicine/image-medicine.png'), // 默认使用瓶子图标

View File

@@ -10,7 +10,6 @@ import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
import { getItem, setItem } from '@/utils/kvStore';
import { log } from '@/utils/logger';
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
@@ -18,7 +17,7 @@ import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
@@ -37,7 +36,7 @@ export default function PersonalScreen() {
sendNotification,
} = useNotifications();
const [notificationEnabled, setNotificationEnabled] = useState(false);
// 移除 notificationEnabled 状态,因为现在在通知设置页面中管理
// 开发者模式相关状态
const [showDeveloperSection, setShowDeveloperSection] = useState(false);
@@ -67,22 +66,13 @@ export default function PersonalScreen() {
React.useCallback(() => {
dispatch(fetchMyProfile());
dispatch(fetchActivityHistory());
// 加载用户推送偏好设置
loadNotificationPreference();
// 不再需要在这里加载推送偏好设置,因为已移到通知设置页面
// 加载开发者模式状态
loadDeveloperModeState();
}, [dispatch])
);
// 加载用户推送偏好设置
const loadNotificationPreference = async () => {
try {
const enabled = await getNotificationEnabled();
setNotificationEnabled(enabled);
} catch (error) {
console.error('加载推送偏好设置失败:', error);
}
};
// 移除 loadNotificationPreference 函数,因为已移到通知设置页面
// 加载开发者模式状态
const loadDeveloperModeState = async () => {
@@ -127,9 +117,8 @@ export default function PersonalScreen() {
// 显示名称
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
// 初始化时加载推送偏好设置和开发者模式状态
// 初始化时加载开发者模式状态
useEffect(() => {
loadNotificationPreference();
loadDeveloperModeState();
}, []);
@@ -160,50 +149,7 @@ export default function PersonalScreen() {
}
};
// 处理通知开关变化
const handleNotificationToggle = async (value: boolean) => {
if (value) {
try {
// 先检查系统权限
const status = await requestPermission();
if (status === 'granted') {
// 系统权限获取成功,保存用户偏好设置
await saveNotificationEnabled(true);
setNotificationEnabled(true);
// 发送测试通知
await sendNotification({
title: '通知已开启',
body: '您将收到运动提醒和重要通知',
sound: true,
priority: 'normal',
});
} else {
// 系统权限被拒绝,不更新用户偏好设置
Alert.alert(
'权限被拒绝',
'请在系统设置中开启通知权限,然后再尝试开启推送功能',
[
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() }
]
);
}
} catch (error) {
console.error('开启推送通知失败:', error);
Alert.alert('错误', '请求通知权限失败');
}
} else {
try {
// 关闭推送,保存用户偏好设置
await saveNotificationEnabled(false);
setNotificationEnabled(false);
} catch (error) {
console.error('关闭推送通知失败:', error);
Alert.alert('错误', '保存设置失败');
}
}
};
// 移除 handleNotificationToggle 函数,因为已移到通知设置页面
// 用户信息头部
const UserHeader = () => (
@@ -412,10 +358,8 @@ export default function PersonalScreen() {
items: [
{
icon: 'notifications-outline' as const,
title: '消息推送',
type: 'switch' as const,
switchValue: notificationEnabled,
onSwitchChange: handleNotificationToggle,
title: '通知设置',
onPress: () => pushIfAuthedElseLogin(ROUTES.NOTIFICATION_SETTINGS),
}
],
},

View File

@@ -3,9 +3,11 @@ import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { HeaderBar } from '@/components/ui/HeaderBar';
import InfoCard from '@/components/ui/InfoCard';
import { Colors } from '@/constants/Colors';
import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_LABELS, FORM_OPTIONS, TIMES_PER_DAY_OPTIONS } from '@/constants/Medication';
import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_LABELS, FORM_OPTIONS } from '@/constants/Medication';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { medicationNotificationService } from '@/services/medicationNotifications';
import { getMedicationById, getMedicationRecords } from '@/services/medications';
import {
deleteMedicationAction,
@@ -13,9 +15,8 @@ import {
selectMedications,
updateMedicationAction,
} from '@/store/medicationsSlice';
import type { Medication, MedicationForm, RepeatPattern } from '@/types/medication';
import type { Medication, MedicationForm } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
import { Picker } from '@react-native-picker/picker';
import Voice from '@react-native-voice/voice';
import dayjs from 'dayjs';
@@ -96,69 +97,6 @@ export default function MedicationDetailScreen() {
const [formPicker, setFormPicker] = useState<MedicationForm>(
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(() => {
if (!medicationFromStore) {
@@ -174,35 +112,13 @@ export default function MedicationDetailScreen() {
}, [medicationFromStore]);
useEffect(() => {
// 同步剂量选择器剂型选择器、频率选择器和时间选择器的默认值
// 同步剂量选择器剂型选择器的默认值
if (medication) {
setDosageValuePicker(medication.dosageValue);
setDosageUnitPicker(medication.dosageUnit);
setFormPicker(medication.form);
setRepeatPatternPicker(medication.repeatPattern);
setTimesPerDayPicker(medication.timesPerDay);
setMedicationTimesPicker(medication.medicationTimes || []);
}
}, [medication?.dosageValue, medication?.dosageUnit, medication?.form, medication?.repeatPattern, medication?.timesPerDay, medication?.medicationTimes]);
// 根据 timesPerDayPicker 动态调整 medicationTimesPicker与 add-medication.tsx 逻辑一致)
useEffect(() => {
setMedicationTimesPicker((prev) => {
if (timesPerDayPicker > prev.length) {
// 需要添加更多时间
const next = [...prev];
while (next.length < timesPerDayPicker) {
next.push(getDefaultTimeByIndex(next.length));
}
return next;
}
if (timesPerDayPicker < prev.length) {
// 需要删除多余时间
return prev.slice(0, timesPerDayPicker);
}
return prev;
});
}, [timesPerDayPicker, getDefaultTimeByIndex]);
}, [medication?.dosageValue, medication?.dosageUnit, medication?.form]);
useEffect(() => {
setNoteDraft(medication?.note ?? '');
@@ -434,6 +350,20 @@ export default function MedicationDetailScreen() {
})
).unwrap();
setMedication(updated);
// 重新安排药品通知
try {
if (nextValue) {
// 如果激活了药品,安排通知
await medicationNotificationService.scheduleMedicationNotifications(updated);
} else {
// 如果停用了药品,取消通知
await medicationNotificationService.cancelMedicationNotifications(updated.id);
}
} catch (error) {
console.error('[MEDICATION] 处理药品通知失败:', error);
// 不影响药品状态切换的成功流程,只记录错误
}
} catch (err) {
console.error('切换药品状态失败', err);
Alert.alert('操作失败', '切换提醒状态时出现问题,请稍后重试。');
@@ -509,6 +439,15 @@ export default function MedicationDetailScreen() {
try {
setDeleteLoading(true);
setDeleteSheetVisible(false); // 立即关闭确认对话框
// 先取消该药品的通知
try {
await medicationNotificationService.cancelMedicationNotifications(medication.id);
} catch (error) {
console.error('[MEDICATION] 取消药品通知失败:', error);
// 不影响药品删除的成功流程,只记录错误
}
await dispatch(deleteMedicationAction(medication.id)).unwrap();
console.log('[MEDICATION_DETAIL] Delete operation successful, navigating back');
router.back();
@@ -546,8 +485,18 @@ export default function MedicationDetailScreen() {
const handleFrequencyPress = useCallback(() => {
if (!medication) return;
setFrequencyPickerVisible(true);
}, [medication]);
// 跳转到独立的频率编辑页面
router.push({
pathname: ROUTES.MEDICATION_EDIT_FREQUENCY,
params: {
medicationId: medication.id,
medicationName: medication.name,
repeatPattern: medication.repeatPattern,
timesPerDay: medication.timesPerDay.toString(),
medicationTimes: medication.medicationTimes.join(','),
},
});
}, [medication, router]);
const confirmDosagePicker = useCallback(async () => {
if (!medication || updatePending) return;
@@ -569,6 +518,14 @@ export default function MedicationDetailScreen() {
})
).unwrap();
setMedication(updated);
// 重新安排药品通知
try {
await medicationNotificationService.scheduleMedicationNotifications(updated);
} catch (error) {
console.error('[MEDICATION] 安排药品通知失败:', error);
// 不影响药品更新的成功流程,只记录错误
}
} catch (err) {
console.error('更新剂量失败', err);
Alert.alert('更新失败', '更新剂量时出现问题,请稍后重试。');
@@ -596,6 +553,14 @@ export default function MedicationDetailScreen() {
})
).unwrap();
setMedication(updated);
// 重新安排药品通知
try {
await medicationNotificationService.scheduleMedicationNotifications(updated);
} catch (error) {
console.error('[MEDICATION] 安排药品通知失败:', error);
// 不影响药品更新的成功流程,只记录错误
}
} catch (err) {
console.error('更新剂型失败', err);
Alert.alert('更新失败', '更新剂型时出现问题,请稍后重试。');
@@ -603,110 +568,6 @@ export default function MedicationDetailScreen() {
setUpdatePending(false);
}
}, [dispatch, formPicker, medication, updatePending]);
const confirmFrequencyPicker = useCallback(async () => {
if (!medication || updatePending) return;
setFrequencyPickerVisible(false);
// 检查频率和时间是否都没有变化
const frequencyChanged = repeatPatternPicker !== medication.repeatPattern || timesPerDayPicker !== medication.timesPerDay;
const timesChanged = JSON.stringify(medicationTimesPicker) !== JSON.stringify(medication.medicationTimes);
if (!frequencyChanged && !timesChanged) {
return;
}
try {
setUpdatePending(true);
const updated = await dispatch(
updateMedicationAction({
id: medication.id,
repeatPattern: repeatPatternPicker,
timesPerDay: timesPerDayPicker,
medicationTimes: medicationTimesPicker, // 同时更新提醒时间
})
).unwrap();
setMedication(updated);
} catch (err) {
console.error('更新频率失败', err);
Alert.alert('更新失败', '更新服药频率时出现问题,请稍后重试。');
} finally {
setUpdatePending(false);
}
}, [dispatch, repeatPatternPicker, timesPerDayPicker, medicationTimesPicker, medication, updatePending]);
// 打开时间选择器
const openTimePicker = useCallback(
(index?: number) => {
try {
if (typeof index === 'number') {
if (index >= 0 && index < medicationTimesPicker.length) {
setEditingTimeIndex(index);
setTimePickerDate(createDateFromTime(medicationTimesPicker[index]));
} else {
console.error('[MEDICATION] Invalid time index:', index);
return;
}
} else {
setEditingTimeIndex(null);
setTimePickerDate(createDateFromTime(getDefaultTimeByIndex(medicationTimesPicker.length)));
}
setTimePickerVisible(true);
} catch (error) {
console.error('[MEDICATION] Error in openTimePicker:', error);
}
},
[medicationTimesPicker, createDateFromTime, getDefaultTimeByIndex]
);
// 确认时间选择
const confirmTime = useCallback(
(date: Date) => {
try {
if (!date || isNaN(date.getTime())) {
console.error('[MEDICATION] Invalid date provided to confirmTime');
setTimePickerVisible(false);
setEditingTimeIndex(null);
return;
}
const nextValue = formatTime(date);
setMedicationTimesPicker((prev) => {
if (editingTimeIndex == null) {
return [...prev, nextValue];
}
return prev.map((time, idx) => (idx === editingTimeIndex ? nextValue : time));
});
setTimePickerVisible(false);
setEditingTimeIndex(null);
} catch (error) {
console.error('[MEDICATION] Error in confirmTime:', error);
setTimePickerVisible(false);
setEditingTimeIndex(null);
}
},
[editingTimeIndex, formatTime]
);
// 删除时间
const removeTime = useCallback((index: number) => {
setMedicationTimesPicker((prev) => {
if (prev.length === 1) {
return prev; // 至少保留一个时间
}
return prev.filter((_, idx) => idx !== index);
});
// 同时更新 timesPerDayPicker
setTimesPerDayPicker((prev) => Math.max(1, prev - 1));
}, []);
// 添加时间
const addTime = useCallback(() => {
openTimePicker();
// 同时更新 timesPerDayPicker
setTimesPerDayPicker((prev) => prev + 1);
}, [openTimePicker]);
if (!medicationId) {
return (
@@ -889,7 +750,6 @@ export default function MedicationDetailScreen() {
styles.footerBar,
{
paddingBottom: Math.max(insets.bottom, 18),
backgroundColor: colors.pageBackgroundEmphasis,
},
]}
>
@@ -1138,178 +998,6 @@ export default function MedicationDetailScreen() {
</View>
</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 ? (
<ConfirmationSheet
visible={deleteSheetVisible}
@@ -1407,13 +1095,16 @@ const styles = StyleSheet.create({
padding: 20,
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: '#FFFFFF',
alignItems: 'center',
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 12,
shadowOffset: { width: 0, height: 8 },
elevation: 3,
shadowColor: '#7a5af8',
shadowOpacity: 0.12,
shadowRadius: 16,
shadowOffset: { width: 0, height: 4 },
elevation: 5,
gap: 12,
borderWidth: 1,
borderColor: 'rgba(122, 90, 248, 0.08)',
},
heroInfo: {
flexDirection: 'row',
@@ -1465,6 +1156,13 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
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: {
flexDirection: 'row',
@@ -1491,6 +1189,13 @@ const styles = StyleSheet.create({
borderRadius: 24,
paddingHorizontal: 18,
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: {
flex: 1,
@@ -1510,6 +1215,13 @@ const styles = StyleSheet.create({
borderRadius: 24,
padding: 18,
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: {
width: 44,
@@ -1718,46 +1430,6 @@ const styles = StyleSheet.create({
fontSize: 16,
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: {
position: 'absolute',
top: 4,

View File

@@ -7,6 +7,7 @@ import { useAppDispatch } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { medicationNotificationService } from '@/services/medicationNotifications';
import { createMedicationAction, fetchMedicationRecords, fetchMedications } from '@/store/medicationsSlice';
import type { MedicationForm, RepeatPattern } from '@/types/medication';
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
@@ -288,6 +289,16 @@ export default function AddMedicationScreen() {
const today = dayjs().format('YYYY-MM-DD');
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(
'添加成功',
@@ -357,42 +368,99 @@ export default function AddMedicationScreen() {
}
}, [dictationActive, dictationLoading, isDictationSupported]);
const handleTakePhoto = useCallback(async () => {
try {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相机权限以拍摄药品照片');
return;
}
// 处理图片选择(拍照或相册)
const handleSelectPhoto = useCallback(() => {
Alert.alert(
'选择图片',
'请选择图片来源',
[
{
text: '拍照',
onPress: async () => {
try {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相机权限以拍摄药品照片');
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.9,
});
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 0.9,
aspect: [9,16]
});
if (result.canceled || !result.assets?.length) {
return;
}
if (result.canceled || !result.assets?.length) {
return;
}
const asset = result.assets[0];
setPhotoPreview(asset.uri);
setPhotoUrl(null);
const asset = result.assets[0];
setPhotoPreview(asset.uri);
setPhotoUrl(null);
try {
const { url } = await upload(
{ uri: asset.uri, name: asset.fileName ?? `medication-${Date.now()}.jpg`, type: asset.mimeType ?? 'image/jpeg' },
{ prefix: 'images/medications' }
);
setPhotoUrl(url);
} catch (uploadError) {
console.error('[MEDICATION] 图片上传失败', uploadError);
Alert.alert('上传失败', '图片上传失败,请稍后重试');
}
} catch (error) {
console.error('[MEDICATION] 拍照失败', error);
Alert.alert('拍照失败', '无法打开相机,请稍后再试');
}
try {
const { url } = await upload(
{ uri: asset.uri, name: asset.fileName ?? `medication-${Date.now()}.jpg`, type: asset.mimeType ?? 'image/jpeg' },
{ prefix: 'images/medications' }
);
setPhotoUrl(url);
} catch (uploadError) {
console.error('[MEDICATION] 图片上传失败', uploadError);
Alert.alert('上传失败', '图片上传失败,请稍后重试');
}
} catch (error) {
console.error('[MEDICATION] 拍照失败', error);
Alert.alert('拍照失败', '无法打开相机,请稍后再试');
}
},
},
{
text: '从相册选择',
onPress: async () => {
try {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相册权限以选择药品照片');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: true,
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.9,
});
if (result.canceled || !result.assets?.length) {
return;
}
const asset = result.assets[0];
setPhotoPreview(asset.uri);
setPhotoUrl(null);
try {
const { url } = await upload(
{ uri: asset.uri, name: asset.fileName ?? `medication-${Date.now()}.jpg`, type: asset.mimeType ?? 'image/jpeg' },
{ prefix: 'images/medications' }
);
setPhotoUrl(url);
} catch (uploadError) {
console.error('[MEDICATION] 图片上传失败', uploadError);
Alert.alert('上传失败', '图片上传失败,请稍后重试');
}
} catch (error) {
console.error('[MEDICATION] 从相册选择失败', error);
Alert.alert('选择失败', '无法打开相册,请稍后再试');
}
},
},
{
text: '取消',
style: 'cancel',
},
],
{ cancelable: true }
);
}, [upload]);
const handleRemovePhoto = useCallback(() => {
@@ -539,7 +607,7 @@ export default function AddMedicationScreen() {
backgroundColor: colors.surface,
},
]}
onPress={handleTakePhoto}
onPress={handleSelectPhoto}
disabled={uploading}
>
{photoPreview ? (
@@ -548,7 +616,7 @@ export default function AddMedicationScreen() {
<View style={styles.photoOverlay}>
<Ionicons name="camera" size={18} color="#fff" />
<ThemedText style={styles.photoOverlayText}>
{uploading ? '上传中…' : '重新拍摄'}
{uploading ? '上传中…' : '重新选择'}
</ThemedText>
</View>
<Pressable style={styles.photoRemoveBtn} onPress={handleRemovePhoto} hitSlop={12}>
@@ -560,8 +628,8 @@ export default function AddMedicationScreen() {
<View style={[styles.photoIconBadge, { backgroundColor: `${colors.primary}12` }]}>
<Ionicons name="camera" size={22} color={colors.primary} />
</View>
<ThemedText style={[styles.photoTitle, { color: colors.text }]}></ThemedText>
<ThemedText style={[styles.photoSubtitle, { color: colors.textMuted }]}></ThemedText>
<ThemedText style={[styles.photoTitle, { color: colors.text }]}></ThemedText>
<ThemedText style={[styles.photoSubtitle, { color: colors.textMuted }]}></ThemedText>
</View>
)}
{uploading && (

View 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',
},
});

View File

@@ -46,7 +46,7 @@ const FILTER_CONFIG: Array<{ key: FilterType; label: string }> = [
{ key: 'inactive', label: '已停用' },
];
const DEFAULT_IMAGE = require('@/assets/images/icons/icon-healthy-diet.png');
const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.png');
export default function ManageMedicationsScreen() {
const dispatch = useAppDispatch();

View 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,
},
});

View File

@@ -119,14 +119,16 @@ const styles = StyleSheet.create({
flex: 1,
borderRadius: 20,
padding: 16,
backgroundColor: '#fff',
backgroundColor: '#FFFFFF',
gap: 6,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 2,
shadowOpacity: 0.06,
shadowRadius: 12,
shadowOffset: { width: 0, height: 3 },
elevation: 3,
position: 'relative',
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.04)',
},
infoCardArrow: {
position: 'absolute',

View File

@@ -68,6 +68,12 @@ export const ROUTES = {
// 开发者相关路由
DEVELOPER: '/developer',
DEVELOPER_LOGS: '/developer/logs',
// 通知设置路由
NOTIFICATION_SETTINGS: '/notification-settings',
// 药品相关路由
MEDICATION_EDIT_FREQUENCY: '/medications/edit-frequency',
} as const;
// 路由参数常量

View 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();

View File

@@ -204,6 +204,11 @@ export class NotificationService {
console.log('用户点击了锻炼完成通知', data);
// 跳转到锻炼历史页面
router.push('/workout/history' as any);
} else if (data?.type === NotificationTypes.MEDICATION_REMINDER) {
// 处理药品提醒通知
console.log('用户点击了药品提醒通知', data);
// 跳转到药品页面
router.push('/(tabs)/medications' as any);
}
}
@@ -538,6 +543,7 @@ export const NotificationTypes = {
WORKOUT_COMPLETION: 'workout_completion',
FASTING_START: 'fasting_start',
FASTING_END: 'fasting_end',
MEDICATION_REMINDER: 'medication_reminder',
} as const;
// 便捷方法
@@ -574,3 +580,22 @@ export const sendMoodCheckinReminder = (title: string, body: string, date?: Date
return notificationService.sendImmediateNotification(notification);
}
};
export const sendMedicationReminder = (title: string, body: string, medicationId?: string, date?: Date) => {
const notification: NotificationData = {
title,
body,
data: {
type: NotificationTypes.MEDICATION_REMINDER,
medicationId: medicationId || ''
},
sound: true,
priority: 'high',
};
if (date) {
return notificationService.scheduleNotificationAtDate(notification, date);
} else {
return notificationService.sendImmediateNotification(notification);
}
};

View File

@@ -11,6 +11,7 @@ const PREFERENCES_KEYS = {
WATER_REMINDER_START_TIME: 'user_preference_water_reminder_start_time',
WATER_REMINDER_END_TIME: 'user_preference_water_reminder_end_time',
WATER_REMINDER_INTERVAL: 'user_preference_water_reminder_interval',
MEDICATION_REMINDER_ENABLED: 'user_preference_medication_reminder_enabled',
} as const;
// 用户偏好设置接口
@@ -24,6 +25,7 @@ export interface UserPreferences {
waterReminderStartTime: string; // 格式: "08:00"
waterReminderEndTime: string; // 格式: "22:00"
waterReminderInterval: number; // 分钟
medicationReminderEnabled: boolean;
}
// 默认的用户偏好设置
@@ -37,6 +39,7 @@ const DEFAULT_PREFERENCES: UserPreferences = {
waterReminderStartTime: '08:00', // 默认开始时间早上8点
waterReminderEndTime: '22:00', // 默认结束时间晚上10点
waterReminderInterval: 60, // 默认提醒间隔60分钟
medicationReminderEnabled: true, // 默认开启药品提醒
};
/**
@@ -53,6 +56,7 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
const waterReminderStartTime = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_START_TIME);
const waterReminderEndTime = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_END_TIME);
const waterReminderInterval = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_INTERVAL);
const medicationReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED);
return {
quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount,
@@ -64,6 +68,7 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
waterReminderStartTime: waterReminderStartTime || DEFAULT_PREFERENCES.waterReminderStartTime,
waterReminderEndTime: waterReminderEndTime || DEFAULT_PREFERENCES.waterReminderEndTime,
waterReminderInterval: waterReminderInterval ? parseInt(waterReminderInterval, 10) : DEFAULT_PREFERENCES.waterReminderInterval,
medicationReminderEnabled: medicationReminderEnabled !== null ? medicationReminderEnabled === 'true' : DEFAULT_PREFERENCES.medicationReminderEnabled,
};
} catch (error) {
console.error('获取用户偏好设置失败:', error);
@@ -375,8 +380,35 @@ export const resetUserPreferences = async (): Promise<void> => {
await AsyncStorage.removeItem(PREFERENCES_KEYS.WATER_REMINDER_START_TIME);
await AsyncStorage.removeItem(PREFERENCES_KEYS.WATER_REMINDER_END_TIME);
await AsyncStorage.removeItem(PREFERENCES_KEYS.WATER_REMINDER_INTERVAL);
await AsyncStorage.removeItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED);
} catch (error) {
console.error('重置用户偏好设置失败:', error);
throw error;
}
};
/**
* 设置药品提醒开关
* @param enabled 是否开启药品提醒
*/
export const setMedicationReminderEnabled = async (enabled: boolean): Promise<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;
}
};