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

@@ -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,