diff --git a/app/(tabs)/medications.tsx b/app/(tabs)/medications.tsx index fa0efff..14a5d94 100644 --- a/app/(tabs)/medications.tsx +++ b/app/(tabs)/medications.tsx @@ -52,6 +52,13 @@ export default function MedicationsScreen() { router.push('/medications/manage-medications'); }, []); + const handleOpenMedicationDetails = useCallback((medicationId: string) => { + router.push({ + pathname: '/medications/[medicationId]', + params: { medicationId }, + }); + }, []); + // 加载药物和记录数据 useEffect(() => { dispatch(fetchMedications()); @@ -255,6 +262,7 @@ export default function MedicationsScreen() { medication={item} colors={colors} selectedDate={selectedDate} + onOpenDetails={() => handleOpenMedicationDetails(item.medicationId)} /> ))} diff --git a/app/medications/[medicationId].tsx b/app/medications/[medicationId].tsx new file mode 100644 index 0000000..78be332 --- /dev/null +++ b/app/medications/[medicationId].tsx @@ -0,0 +1,1036 @@ +import { ThemedText } from '@/components/ThemedText'; +import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet'; +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { Colors } from '@/constants/Colors'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { getMedicationById, getMedicationRecords } from '@/services/medications'; +import { + deleteMedicationAction, + fetchMedications, + selectMedications, + updateMedicationAction, +} from '@/store/medicationsSlice'; +import type { Medication } from '@/types/medication'; +import { Ionicons } from '@expo/vector-icons'; +import Voice from '@react-native-voice/voice'; +import dayjs from 'dayjs'; +import { Image } from 'expo-image'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Keyboard, + Modal, + Platform, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const FORM_LABELS: Record = { + capsule: '胶囊', + pill: '药片', + injection: '注射', + spray: '喷雾', + drop: '滴剂', + syrup: '糖浆', + other: '其他', +}; + +const DEFAULT_IMAGE = require('@/assets/images/icons/icon-healthy-diet.png'); + +type RecordsSummary = { + takenCount: number; + startedDays: number | null; +}; + +export default function MedicationDetailScreen() { + const params = useLocalSearchParams<{ medicationId?: string }>(); + const medicationId = Array.isArray(params.medicationId) + ? params.medicationId[0] + : params.medicationId; + const dispatch = useAppDispatch(); + const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors; + const colors = Colors[scheme]; + const insets = useSafeAreaInsets(); + const router = useRouter(); + + const medications = useAppSelector(selectMedications); + const medicationFromStore = medications.find((item) => item.id === medicationId); + + const [medication, setMedication] = useState(medicationFromStore ?? null); + const [loading, setLoading] = useState(!medicationFromStore); + const [summary, setSummary] = useState({ + takenCount: 0, + startedDays: null, + }); + const [summaryLoading, setSummaryLoading] = useState(true); + const [updatePending, setUpdatePending] = useState(false); + const [error, setError] = useState(null); + const [noteModalVisible, setNoteModalVisible] = useState(false); + const [noteDraft, setNoteDraft] = useState(medication?.note ?? ''); + const [noteSaving, setNoteSaving] = useState(false); + const [dictationActive, setDictationActive] = useState(false); + const [dictationLoading, setDictationLoading] = useState(false); + const isDictationSupported = Platform.OS === 'ios'; + const [keyboardHeight, setKeyboardHeight] = useState(0); + const [deleteSheetVisible, setDeleteSheetVisible] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); + + useEffect(() => { + if (!medicationFromStore) { + dispatch(fetchMedications()); + } + }, [dispatch, medicationFromStore]); + + useEffect(() => { + if (medicationFromStore) { + setMedication(medicationFromStore); + setLoading(false); + } + }, [medicationFromStore]); + + useEffect(() => { + setNoteDraft(medication?.note ?? ''); + }, [medication?.note]); + + useEffect(() => { + let isMounted = true; + let abortController = new AbortController(); + + console.log('[MEDICATION_DETAIL] useEffect triggered', { + medicationId, + hasMedicationFromStore: !!medicationFromStore, + deleteLoading + }); + + // 如果正在删除操作中,不执行任何操作 + if (deleteLoading) { + console.log('[MEDICATION_DETAIL] Delete operation in progress, skipping useEffect'); + return () => { + isMounted = false; + abortController.abort(); + }; + } + + if (!medicationId || medicationFromStore) { + console.log('[MEDICATION_DETAIL] Early return from useEffect', { + hasMedicationId: !!medicationId, + hasMedicationFromStore: !!medicationFromStore + }); + return () => { + isMounted = false; + abortController.abort(); + }; + } + + console.log('[MEDICATION_DETAIL] Starting API call for medication', medicationId); + setLoading(true); + getMedicationById(medicationId) + .then((data) => { + if (!isMounted || abortController.signal.aborted) return; + console.log('[MEDICATION_DETAIL] API call successful', data); + setMedication(data); + setError(null); + }) + .catch((err) => { + if (abortController.signal.aborted) return; + console.error('加载药品详情失败', err); + console.log('[MEDICATION_DETAIL] API call failed for medication', medicationId, err); + if (isMounted) { + setError('暂时无法获取该药品的信息,请稍后重试。'); + } + }) + .finally(() => { + if (isMounted && !abortController.signal.aborted) { + setLoading(false); + } + }); + + return () => { + isMounted = false; + abortController.abort(); + }; + }, [medicationId, medicationFromStore, deleteLoading]); + + useEffect(() => { + let isMounted = true; + if (!medicationId) { + return () => { + isMounted = false; + }; + } + + setSummaryLoading(true); + getMedicationRecords({ medicationId }) + .then((records) => { + if (!isMounted) return; + const takenCount = records.filter((record) => record.status === 'taken').length; + const earliestRecord = records.reduce((earliest, record) => { + const current = new Date(record.scheduledTime); + if (!earliest || current < earliest) { + return current; + } + return earliest; + }, null); + const startedDaysRaw = earliestRecord + ? dayjs().diff(dayjs(earliestRecord), 'day') + : medication + ? dayjs().diff(dayjs(medication.startDate), 'day') + : null; + const startedDays = typeof startedDaysRaw === 'number' + ? Math.max(startedDaysRaw, 0) + : null; + setSummary({ + takenCount, + startedDays: startedDays ?? null, + }); + }) + .catch((err) => { + console.error('加载服药记录失败', err); + }) + .finally(() => { + if (isMounted) { + setSummaryLoading(false); + } + }); + + return () => { + isMounted = false; + }; + }, [medicationId, medication]); + + const appendDictationResult = useCallback((text: string) => { + const clean = text.trim(); + if (!clean) return; + setNoteDraft((prev) => { + if (!prev) return clean; + return `${prev}${prev.endsWith('\n') ? '' : '\n'}${clean}`; + }); + }, []); + + useEffect(() => { + if (!isDictationSupported || !noteModalVisible) { + return; + } + + Voice.onSpeechStart = () => { + setDictationActive(true); + setDictationLoading(false); + }; + + Voice.onSpeechEnd = () => { + setDictationActive(false); + setDictationLoading(false); + }; + + Voice.onSpeechResults = (event: any) => { + const recognized = event?.value?.[0]; + if (recognized) { + appendDictationResult(recognized); + } + }; + + Voice.onSpeechError = (error: any) => { + console.log('[MEDICATION_DETAIL] voice error', error); + setDictationActive(false); + setDictationLoading(false); + Alert.alert('语音识别不可用', '无法使用语音输入,请检查权限设置后重试'); + }; + + return () => { + Voice.destroy() + .then(() => { + Voice.removeAllListeners(); + }) + .catch(() => {}); + }; + }, [appendDictationResult, isDictationSupported, noteModalVisible]); + + useEffect(() => { + if (!noteModalVisible) { + setKeyboardHeight(0); + return; + } + + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + + const handleShow = (event: any) => { + const height = event?.endCoordinates?.height ?? 0; + setKeyboardHeight(height); + }; + const handleHide = () => setKeyboardHeight(0); + + const showSub = Keyboard.addListener(showEvent, handleShow); + const hideSub = Keyboard.addListener(hideEvent, handleHide); + + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, [noteModalVisible]); + + const handleDictationPress = useCallback(async () => { + if (!isDictationSupported || dictationLoading) { + return; + } + + try { + if (dictationActive) { + setDictationLoading(true); + await Voice.stop(); + setDictationLoading(false); + return; + } + + setDictationLoading(true); + try { + await Voice.stop(); + } catch { + // ignore if not recording + } + await Voice.start('zh-CN'); + } catch (error) { + console.log('[MEDICATION_DETAIL] unable to start dictation', error); + setDictationLoading(false); + Alert.alert('无法启动语音输入', '请检查麦克风与语音识别权限后重试'); + } + }, [dictationActive, dictationLoading, isDictationSupported]); + + const closeNoteModal = useCallback(() => { + setNoteModalVisible(false); + if (dictationActive) { + Voice.stop().catch(() => {}); + } + setDictationActive(false); + setDictationLoading(false); + setKeyboardHeight(0); + }, [dictationActive]); + + const handleToggleMedication = async (nextValue: boolean) => { + if (!medication || updatePending) return; + + try { + setUpdatePending(true); + const updated = await dispatch( + updateMedicationAction({ + id: medication.id, + isActive: nextValue, + }) + ).unwrap(); + setMedication(updated); + } catch (err) { + console.error('切换药品状态失败', err); + Alert.alert('操作失败', '切换提醒状态时出现问题,请稍后重试。'); + } finally { + setUpdatePending(false); + } + }; + + const formLabel = medication ? FORM_LABELS[medication.form] : ''; + const dosageLabel = medication ? `${medication.dosageValue} ${medication.dosageUnit}` : '--'; + const startDateLabel = medication + ? dayjs(medication.startDate).format('YYYY年M月D日') + : '--'; + const reminderTimes = medication?.medicationTimes?.length + ? medication.medicationTimes.join('、') + : '尚未设置'; + const frequencyLabel = useMemo(() => { + if (!medication) return '--'; + switch (medication.repeatPattern) { + case 'daily': + return `每日 ${medication.timesPerDay} 次`; + case 'weekly': + return `每周 ${medication.timesPerDay} 次`; + default: + return `自定义 · ${medication.timesPerDay} 次/日`; + } + }, [medication]); + + const handleOpenNoteModal = useCallback(() => { + setNoteDraft(medication?.note ?? ''); + setNoteModalVisible(true); + }, [medication?.note]); + + const handleSaveNote = useCallback(async () => { + if (!medication) return; + const trimmed = noteDraft.trim(); + setNoteSaving(true); + try { + const updated = await dispatch( + updateMedicationAction({ + id: medication.id, + note: trimmed || undefined, + }) + ).unwrap(); + setMedication(updated); + closeNoteModal(); + } catch (err) { + console.error('保存备注失败', err); + Alert.alert('保存失败', '提交备注时出现问题,请稍后重试。'); + } finally { + setNoteSaving(false); + } + }, [closeNoteModal, dispatch, medication, noteDraft]); + + const statusLabel = medication?.isActive ? '提醒已开启' : '提醒已关闭'; + const noteText = medication?.note?.trim() ? medication.note : '暂无备注信息'; + const dayStreakText = + typeof summary.startedDays === 'number' + ? `已坚持 ${summary.startedDays} 天` + : medication + ? `开始于 ${dayjs(medication.startDate).format('YYYY年M月D日')}` + : '暂无开始日期'; + + const handleDeleteMedication = useCallback(async () => { + if (!medication || deleteLoading) { + console.log('[MEDICATION_DETAIL] Delete aborted', { + hasMedication: !!medication, + deleteLoading + }); + return; + } + console.log('[MEDICATION_DETAIL] Starting delete operation for medication', medication.id); + try { + setDeleteLoading(true); + setDeleteSheetVisible(false); // 立即关闭确认对话框 + await dispatch(deleteMedicationAction(medication.id)).unwrap(); + console.log('[MEDICATION_DETAIL] Delete operation successful, navigating back'); + router.back(); + } catch (err) { + console.error('删除药品失败', err); + Alert.alert('删除失败', '移除该药品时出现问题,请稍后再试。'); + } finally { + setDeleteLoading(false); + } + }, [deleteLoading, dispatch, medication, router]); + + if (!medicationId) { + return ( + + + + 未找到药品信息 + 请从用药列表重新进入此页面。 + + + ); + } + + const isLoadingState = loading && !medication; + const contentBottomPadding = Math.max(insets.bottom, 16) + 140; + + return ( + + + {isLoadingState ? ( + + + 正在载入... + + ) : error ? ( + + {error} + + 请检查网络后重试,或返回上一页。 + + + ) : medication ? ( + + + + + + + + {medication.name} + + {dosageLabel} · {formLabel} + + + + + + + + +
+ + + + + + + + 频率 + + + {frequencyLabel} + + + +
+ +
+ + + + +
+ +
+ + + + 药品备注 + + {noteText} + + + + +
+ +
+ + + + + + + {summaryLoading ? '统计中...' : `累计服药 ${summary.takenCount} 次`} + + + {summaryLoading ? '正在计算坚持天数' : dayStreakText} + + + +
+
+ ) : null} + + {medication ? ( + + setDeleteSheetVisible(true)} + > + + 删除该药品 + + + ) : null} + + + + + + + + + 编辑备注 + + + + + + + + {isDictationSupported && ( + + {dictationLoading ? ( + + ) : ( + + )} + + )} + + {!isDictationSupported && ( + + 当前设备暂不支持语音转文字,可直接输入备注 + + )} + + + {noteSaving ? ( + + ) : ( + 保存 + )} + + + + + + + {medication ? ( + setDeleteSheetVisible(false)} + onConfirm={handleDeleteMedication} + title={`删除 ${medication.name}?`} + description="删除后将清除与该药品相关的提醒与历史记录,且无法恢复。" + confirmText="删除" + cancelText="取消" + destructive + loading={deleteLoading} + /> + ) : null} +
+ ); +} + +const Section = ({ + title, + children, + color, +}: { + title: string; + children: React.ReactNode; + color: string; +}) => { + return ( + + {title} + {children} + + ); +}; + +const InfoCard = ({ + label, + value, + icon, + colors, +}: { + label: string; + value: string; + icon: keyof typeof Ionicons.glyphMap; + colors: (typeof Colors)[keyof typeof Colors]; +}) => { + return ( + + + + + {label} + {value} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + position: 'relative', + }, + content: { + paddingHorizontal: 20, + gap: 24, + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + loadingText: { + marginTop: 8, + fontSize: 14, + }, + emptyTitle: { + fontSize: 18, + fontWeight: '600', + textAlign: 'center', + }, + emptySubtitle: { + fontSize: 14, + textAlign: 'center', + }, + heroCard: { + borderRadius: 28, + padding: 20, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + shadowColor: '#000', + shadowOpacity: 0.05, + shadowRadius: 12, + shadowOffset: { width: 0, height: 8 }, + elevation: 3, + gap: 12, + }, + heroInfo: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + flex: 1, + }, + heroImageWrapper: { + width: 64, + height: 64, + borderRadius: 20, + backgroundColor: '#F2F2F2', + alignItems: 'center', + justifyContent: 'center', + }, + heroImage: { + width: '80%', + height: '80%', + }, + heroTitle: { + fontSize: 20, + fontWeight: '700', + }, + heroMeta: { + marginTop: 4, + fontSize: 13, + fontWeight: '500', + }, + heroToggle: { + alignItems: 'flex-end', + gap: 6, + }, + section: { + gap: 12, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '700', + }, + row: { + flexDirection: 'row', + gap: 12, + }, + infoCard: { + flex: 1, + borderRadius: 20, + padding: 16, + backgroundColor: '#fff', + gap: 6, + shadowColor: '#000', + shadowOpacity: 0.04, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 2, + }, + infoCardIcon: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: '#EEF1FF', + alignItems: 'center', + justifyContent: 'center', + }, + infoCardLabel: { + fontSize: 13, + color: '#6B7280', + marginTop: 8, + }, + infoCardValue: { + fontSize: 16, + fontWeight: '600', + color: '#1F2933', + }, + fullCard: { + borderRadius: 22, + padding: 18, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + fullCardLeading: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + fullCardLabel: { + fontSize: 15, + fontWeight: '600', + }, + fullCardTrailing: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + fullCardValue: { + fontSize: 16, + fontWeight: '600', + }, + noteCard: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + borderRadius: 24, + paddingHorizontal: 18, + paddingVertical: 16, + }, + noteBody: { + flex: 1, + gap: 4, + }, + noteLabel: { + fontSize: 14, + fontWeight: '600', + }, + noteValue: { + fontSize: 14, + lineHeight: 20, + }, + summaryCard: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 24, + padding: 18, + gap: 16, + }, + summaryIcon: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: '#EEF1FF', + alignItems: 'center', + justifyContent: 'center', + }, + summaryBody: { + flex: 1, + gap: 4, + }, + summaryHighlight: { + fontSize: 16, + fontWeight: '700', + }, + summaryMeta: { + fontSize: 14, + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.35)', + justifyContent: 'flex-end', + }, + modalBackdrop: { + flex: 1, + }, + modalContainer: { + width: '100%', + paddingHorizontal: 20, + }, + modalCard: { + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: 32, + gap: 16, + }, + modalHandle: { + width: 60, + height: 5, + borderRadius: 2.5, + backgroundColor: 'rgba(0,0,0,0.12)', + alignSelf: 'center', + marginBottom: 12, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + modalTitle: { + fontSize: 18, + fontWeight: '700', + }, + noteEditorWrapper: { + borderWidth: 1, + borderRadius: 24, + paddingHorizontal: 18, + paddingVertical: 24, + minHeight: 120, + paddingRight: 70, + shadowColor: '#000', + shadowOpacity: 0.06, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 3, + }, + noteEditorInput: { + minHeight: 50, + fontSize: 15, + lineHeight: 22, + }, + voiceButton: { + position: 'absolute', + right: 16, + top: 16, + width: 40, + height: 40, + borderRadius: 20, + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + }, + voiceHint: { + fontSize: 12, + }, + modalActionGhostText: { + fontSize: 16, + fontWeight: '600', + }, + modalActionPrimary: { + borderRadius: 22, + height: 52, + alignItems: 'center', + justifyContent: 'center', + marginHorizontal: 8, + shadowOpacity: 0.25, + shadowRadius: 12, + shadowOffset: { width: 0, height: 8 }, + elevation: 4, + }, + modalActionPrimaryText: { + fontSize: 17, + fontWeight: '700', + }, + modalActionContainer: { + marginTop: 8, + }, + footerBar: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingHorizontal: 20, + paddingTop: 16, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(15,23,42,0.06)', + }, + deleteButton: { + height: 56, + borderRadius: 24, + backgroundColor: '#EF4444', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + shadowColor: 'rgba(239,68,68,0.4)', + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 1, + shadowRadius: 20, + elevation: 6, + }, + deleteButtonText: { + fontSize: 17, + fontWeight: '700', + color: '#fff', + }, +}); diff --git a/app/medications/manage-medications.tsx b/app/medications/manage-medications.tsx index 5cdcc1e..28b4ac4 100644 --- a/app/medications/manage-medications.tsx +++ b/app/medications/manage-medications.tsx @@ -59,7 +59,6 @@ export default function ManageMedicationsScreen() { const [activeFilter, setActiveFilter] = useState('all'); const [pendingMedicationId, setPendingMedicationId] = useState(null); - const updateLoading = loading.update; const listLoading = loading.medications && medications.length === 0; useFocusEffect( @@ -116,7 +115,7 @@ export default function ManageMedicationsScreen() { ); // 创建独立的药品卡片组件,使用 React.memo 优化渲染 - const MedicationCard = React.memo(({ medication }: { medication: Medication }) => { + const MedicationCard = React.memo(({ medication, onPress }: { medication: Medication; onPress: () => void }) => { const dosageLabel = `${medication.dosageValue} ${medication.dosageUnit || ''} ${FORM_LABELS[medication.form] ?? ''}`.trim(); const frequencyLabel = `${medication.repeatPattern === 'daily' ? '每日' : medication.repeatPattern === 'weekly' ? '每周' : '自定义'} | ${dosageLabel}`; const startDateLabel = dayjs(medication.startDate).isValid() @@ -127,7 +126,11 @@ export default function ManageMedicationsScreen() { : `${medication.timesPerDay} 次/日`; return ( - + - handleToggleMedication(medication, value)} - disabled={updateLoading || pendingMedicationId === medication.id} - trackColor={{ false: '#D9D9D9', true: colors.primary }} - thumbColor={medication.isActive ? '#fff' : '#fff'} - ios_backgroundColor="#D9D9D9" - /> - + + handleToggleMedication(medication, value)} + disabled={pendingMedicationId === medication.id} + trackColor={{ false: '#D9D9D9', true: colors.primary }} + thumbColor={medication.isActive ? '#fff' : '#fff'} + ios_backgroundColor="#D9D9D9" + /> + {pendingMedicationId === medication.id && ( + + )} + + ); }, (prevProps, nextProps) => { // 自定义比较函数,只有当药品的 isActive 状态或 ID 改变时才重新渲染 @@ -166,11 +178,24 @@ export default function ManageMedicationsScreen() { MedicationCard.displayName = 'MedicationCard'; + const handleOpenMedicationDetails = useCallback((medicationId: string) => { + router.push({ + pathname: '/medications/[medicationId]', + params: { medicationId }, + }); + }, []); + const renderMedicationCard = useCallback( (medication: Medication) => { - return ; + return ( + handleOpenMedicationDetails(medication.id)} + /> + ); }, - [handleToggleMedication, pendingMedicationId, updateLoading, colors] + [handleToggleMedication, pendingMedicationId, colors, handleOpenMedicationDetails] ); return ( @@ -398,4 +423,13 @@ const styles = StyleSheet.create({ fontSize: 14, textAlign: 'center', }, + switchContainer: { + alignItems: 'center', + justifyContent: 'center', + position: 'relative', + }, + switchLoading: { + position: 'absolute', + marginLeft: 30, // 确保加载指示器显示在开关旁边 + }, }); diff --git a/components/medication/MedicationCard.tsx b/components/medication/MedicationCard.tsx index 6e65502..f1d507a 100644 --- a/components/medication/MedicationCard.tsx +++ b/components/medication/MedicationCard.tsx @@ -13,9 +13,10 @@ export type MedicationCardProps = { medication: MedicationDisplayItem; colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors]; selectedDate: Dayjs; + onOpenDetails?: (medication: MedicationDisplayItem) => void; }; -export function MedicationCard({ medication, colors, selectedDate }: MedicationCardProps) { +export function MedicationCard({ medication, colors, selectedDate, onOpenDetails }: MedicationCardProps) { const dispatch = useAppDispatch(); const [isSubmitting, setIsSubmitting] = useState(false); @@ -134,34 +135,7 @@ export function MedicationCard({ medication, colors, selectedDate }: MedicationC ); } - if (medication.status === 'missed') { - return ( - { - // 已错过的药物不能服用 - console.log('已错过的药物不能服用'); - }} - > - {isLiquidGlassAvailable() ? ( - - 已错过 - - ) : ( - - 已错过 - - )} - - ); - } - + // 只要没有服药,都可以显示立即服用 return ( + onOpenDetails?.(medication)} + disabled={!onOpenDetails} + > {statusChip ? {statusChip} : null} @@ -226,7 +205,7 @@ export function MedicationCard({ medication, colors, selectedDate }: MedicationC - + ); } @@ -361,4 +340,4 @@ const styles = StyleSheet.create({ fontWeight: '600', color: '#fff', }, -}); \ No newline at end of file +}); diff --git a/components/ui/ConfirmationSheet.tsx b/components/ui/ConfirmationSheet.tsx new file mode 100644 index 0000000..ae34582 --- /dev/null +++ b/components/ui/ConfirmationSheet.tsx @@ -0,0 +1,265 @@ +import * as Haptics from 'expo-haptics'; +import React, { useEffect, useRef, useState } from 'react'; +import { + ActivityIndicator, + Animated, + Dimensions, + Modal, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const { height: screenHeight } = Dimensions.get('window'); + +interface ConfirmationSheetProps { + visible: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + description?: string; + confirmText?: string; + cancelText?: string; + destructive?: boolean; + loading?: boolean; +} + +export function ConfirmationSheet({ + visible, + onClose, + onConfirm, + title, + description, + confirmText = '确认', + cancelText = '取消', + destructive = false, + loading = false, +}: ConfirmationSheetProps) { + const insets = useSafeAreaInsets(); + const translateY = useRef(new Animated.Value(screenHeight)).current; + const backdropOpacity = useRef(new Animated.Value(0)).current; + const [modalVisible, setModalVisible] = useState(visible); + + useEffect(() => { + if (visible) { + setModalVisible(true); + } + }, [visible]); + + useEffect(() => { + if (!modalVisible) { + return; + } + + if (visible) { + translateY.setValue(screenHeight); + backdropOpacity.setValue(0); + + Animated.parallel([ + Animated.timing(backdropOpacity, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + Animated.spring(translateY, { + toValue: 0, + useNativeDriver: true, + bounciness: 6, + speed: 12, + }), + ]).start(); + return; + } + + Animated.parallel([ + Animated.timing(backdropOpacity, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + Animated.timing(translateY, { + toValue: screenHeight, + duration: 240, + useNativeDriver: true, + }), + ]).start(() => { + translateY.setValue(screenHeight); + backdropOpacity.setValue(0); + setModalVisible(false); + }); + }, [visible, modalVisible, backdropOpacity, translateY]); + + const handleCancel = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onClose(); + }; + + const handleConfirm = () => { + if (loading) return; + Haptics.notificationAsync( + destructive ? Haptics.NotificationFeedbackType.Error : Haptics.NotificationFeedbackType.Success + ); + onConfirm(); + }; + + if (!modalVisible) { + return null; + } + + return ( + + + + + + + + + {title} + {description ? {description} : null} + + + + {cancelText} + + + {loading ? ( + + ) : ( + {confirmText} + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'transparent', + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(15, 23, 42, 0.45)', + }, + sheet: { + backgroundColor: '#fff', + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + paddingHorizontal: 24, + paddingTop: 16, + shadowColor: '#000', + shadowOpacity: 0.12, + shadowRadius: 16, + shadowOffset: { width: 0, height: -4 }, + elevation: 16, + gap: 12, + }, + handle: { + width: 50, + height: 4, + borderRadius: 2, + backgroundColor: '#E5E7EB', + alignSelf: 'center', + marginBottom: 8, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: '#111827', + textAlign: 'center', + }, + description: { + fontSize: 15, + color: '#6B7280', + textAlign: 'center', + lineHeight: 22, + }, + actions: { + flexDirection: 'row', + gap: 12, + marginTop: 8, + }, + cancelButton: { + flex: 1, + height: 56, + borderRadius: 18, + borderWidth: 1, + borderColor: '#E5E7EB', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#F8FAFC', + }, + cancelText: { + fontSize: 16, + fontWeight: '600', + color: '#111827', + }, + confirmButton: { + flex: 1, + height: 56, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + shadowColor: 'rgba(239, 68, 68, 0.45)', + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 1, + shadowRadius: 20, + elevation: 6, + }, + primaryButton: { + backgroundColor: '#2563EB', + }, + destructiveButton: { + backgroundColor: '#EF4444', + }, + disabledButton: { + opacity: 0.7, + }, + confirmText: { + fontSize: 16, + fontWeight: '700', + color: '#fff', + }, +}); diff --git a/store/medicationsSlice.ts b/store/medicationsSlice.ts index a9a436c..af9a326 100644 --- a/store/medicationsSlice.ts +++ b/store/medicationsSlice.ts @@ -498,18 +498,25 @@ const medicationsSlice = createSlice({ // ==================== deleteMedication ==================== builder .addCase(deleteMedicationAction.pending, (state) => { + console.log('[MEDICATIONS_SLICE] Delete operation pending'); state.loading.delete = true; state.error = null; }) .addCase(deleteMedicationAction.fulfilled, (state, action) => { + console.log('[MEDICATIONS_SLICE] Delete operation fulfilled', { deletedId: action.payload }); state.loading.delete = false; const deletedId = action.payload; state.medications = state.medications.filter((m) => m.id !== deletedId); state.activeMedications = state.activeMedications.filter( (m) => m.id !== deletedId ); + console.log('[MEDICATIONS_SLICE] Medications after delete', { + totalMedications: state.medications.length, + activeMedications: state.activeMedications.length + }); }) .addCase(deleteMedicationAction.rejected, (state, action) => { + console.log('[MEDICATIONS_SLICE] Delete operation rejected', action.error); state.loading.delete = false; state.error = action.error.message || '删除药物失败'; });