feat(medications): 重构药品通知系统并添加独立设置页面
- 创建药品通知服务模块,统一管理药品提醒通知的调度和取消 - 新增独立的通知设置页面,支持总开关和药品提醒开关分离控制 - 重构药品详情页面,移除频率编辑功能到独立页面 - 优化药品添加流程,支持拍照和相册选择图片 - 改进通知权限检查和错误处理机制 - 更新用户偏好设置,添加药品提醒开关配置
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
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: '已停用' },
|
||||
];
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user