From bcb910140e4ad3320b0c6c45ed4fb9daa89ca056 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 21 Nov 2025 17:32:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(medications):=20=E6=B7=BB=E5=8A=A0AI?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E8=AF=86=E5=88=AB=E8=8D=AF=E5=93=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=92=8C=E6=9C=89=E6=95=88=E6=9C=9F=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增AI药品识别流程,支持多角度拍摄和实时进度显示 - 添加药品有效期字段,支持在添加和编辑药品时设置有效期 - 新增MedicationAddOptionsSheet选择录入方式(AI识别/手动录入) - 新增ai-camera和ai-progress两个独立页面处理AI识别流程 - 新增ExpiryDatePickerModal和MedicationPhotoGuideModal组件 - 移除本地通知系统,迁移到服务端推送通知 - 添加medicationNotificationCleanup服务清理旧的本地通知 - 更新药品详情页支持AI草稿模式和有效期显示 - 优化药品表单,支持有效期选择和AI识别结果确认 - 更新i18n资源,添加有效期相关翻译 BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理 --- app/(tabs)/medications.tsx | 63 +- app/_layout.tsx | 10 +- app/medications/[medicationId].tsx | 553 +++++++++++----- app/medications/add-medication.tsx | 107 ++- app/medications/ai-camera.tsx | 626 ++++++++++++++++++ app/medications/ai-progress.tsx | 514 ++++++++++++++ app/medications/edit-frequency.tsx | 8 - .../medication/MedicationAddOptionsSheet.tsx | 409 ++++++++++++ .../medications/ExpiryDatePickerModal.tsx | 205 ++++++ .../medications/MedicationPhotoGuideModal.tsx | 265 ++++++++ i18n/index.ts | 4 + ios/OutLive/Info.plist | 2 +- ios/OutLive/SplashScreen.storyboard | 6 +- services/medicationNotificationCleanup.ts | 66 ++ services/medicationNotifications.ts | 196 ------ services/medications.ts | 38 ++ services/notifications.ts | 24 - types/medication.ts | 46 ++ 18 files changed, 2735 insertions(+), 407 deletions(-) create mode 100644 app/medications/ai-camera.tsx create mode 100644 app/medications/ai-progress.tsx create mode 100644 components/medication/MedicationAddOptionsSheet.tsx create mode 100644 components/medications/ExpiryDatePickerModal.tsx create mode 100644 components/medications/MedicationPhotoGuideModal.tsx create mode 100644 services/medicationNotificationCleanup.ts delete mode 100644 services/medicationNotifications.ts diff --git a/app/(tabs)/medications.tsx b/app/(tabs)/medications.tsx index 88178fc..ad346eb 100644 --- a/app/(tabs)/medications.tsx +++ b/app/(tabs)/medications.tsx @@ -1,14 +1,17 @@ import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation'; import { DateSelector } from '@/components/DateSelector'; +import { MedicationAddOptionsSheet } from '@/components/medication/MedicationAddOptionsSheet'; import { MedicationCard } from '@/components/medication/MedicationCard'; import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack'; import { ThemedText } from '@/components/ThemedText'; import { IconSymbol } from '@/components/ui/IconSymbol'; import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet'; import { Colors } from '@/constants/Colors'; +import { useMembershipModal } from '@/contexts/MembershipModalContext'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { medicationNotificationService } from '@/services/medicationNotifications'; +import { useVipService } from '@/hooks/useVipService'; import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice'; import { DEFAULT_MEMBER_NAME } from '@/store/userSlice'; import { getItemSync, setItemSync } from '@/utils/kvStore'; @@ -46,6 +49,9 @@ export default function MedicationsScreen() { const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colors: ThemeColors = Colors[theme]; const userProfile = useAppSelector((state) => state.user.profile); + const { ensureLoggedIn } = useAuthGuard(); + const { checkServiceAccess } = useVipService(); + const { openMembershipModal } = useMembershipModal(); const [selectedDate, setSelectedDate] = useState(dayjs()); const [selectedDateIndex, setSelectedDateIndex] = useState(selectedDate.date() - 1); const [activeFilter, setActiveFilter] = useState('all'); @@ -53,34 +59,59 @@ export default function MedicationsScreen() { const celebrationTimerRef = useRef | null>(null); const [isCelebrationVisible, setIsCelebrationVisible] = useState(false); const [disclaimerVisible, setDisclaimerVisible] = useState(false); + const [addSheetVisible, setAddSheetVisible] = useState(false); + const [pendingAction, setPendingAction] = useState<'manual' | null>(null); // 从 Redux 获取数据 const selectedKey = selectedDate.format('YYYY-MM-DD'); const medicationsForDay = useAppSelector((state) => selectMedicationDisplayItemsByDate(selectedKey)(state)); - const handleOpenAddMedication = useCallback(() => { - // 检查是否已经读过免责声明 + const handleOpenAddSheet = useCallback(() => { + setAddSheetVisible(true); + }, []); + + const handleManualAdd = useCallback(() => { const hasRead = getItemSync(MEDICAL_DISCLAIMER_READ_KEY); - + setPendingAction('manual'); + if (hasRead === 'true') { - // 已读过,直接跳转 + setAddSheetVisible(false); + setPendingAction(null); router.push('/medications/add-medication'); } else { - // 未读过,显示医疗免责声明弹窗 + setAddSheetVisible(false); setDisclaimerVisible(true); } }, []); + const handleAiRecognize = useCallback(async () => { + setAddSheetVisible(false); + const isLoggedIn = await ensureLoggedIn(); + if (!isLoggedIn) return; + + const access = checkServiceAccess(); + if (!access.canUseService) { + openMembershipModal(); + return; + } + + router.push('/medications/ai-camera'); + }, [checkServiceAccess, ensureLoggedIn, openMembershipModal, router]); + const handleDisclaimerConfirm = useCallback(() => { // 用户同意免责声明后,记录已读状态,关闭弹窗并跳转到添加页面 setItemSync(MEDICAL_DISCLAIMER_READ_KEY, 'true'); setDisclaimerVisible(false); - router.push('/medications/add-medication'); - }, []); + if (pendingAction === 'manual') { + setPendingAction(null); + router.push('/medications/add-medication'); + } + }, [pendingAction]); const handleDisclaimerClose = useCallback(() => { // 用户不接受免责声明,只关闭弹窗,不跳转,不记录已读状态 setDisclaimerVisible(false); + setPendingAction(null); }, []); const handleOpenMedicationManagement = useCallback(() => { @@ -133,11 +164,8 @@ export default function MedicationsScreen() { // 只获取一次药物数据,然后复用结果 const medications = await dispatch(fetchMedications({ isActive: true })).unwrap(); - // 并行执行获取药物记录和安排通知 - const [recordsAction] = await Promise.all([ - dispatch(fetchMedicationRecords({ date: selectedKey })), - medicationNotificationService.rescheduleAllMedicationNotifications(medications), - ]); + // 获取药物记录 + const recordsAction = await dispatch(fetchMedicationRecords({ date: selectedKey })); // 同步数据到小组件(仅同步今天的) const today = dayjs().format('YYYY-MM-DD'); @@ -274,7 +302,7 @@ export default function MedicationsScreen() { {isLiquidGlassAvailable() ? ( + setAddSheetVisible(false)} + onManualAdd={handleManualAdd} + onAiRecognize={handleAiRecognize} + /> + {/* 医疗免责声明弹窗 */} { + logger.error('❌ 清理旧药品通知失败:', error); + }); + + // 3. 异步同步 Widget 数据(不阻塞主流程) syncWidgetDataInBackground(); logger.info('🎉 权限相关服务初始化完成'); @@ -520,6 +526,8 @@ export default function RootLayout() { name="health-data-permissions" options={{ headerShown: false }} /> + + diff --git a/app/medications/[medicationId].tsx b/app/medications/[medicationId].tsx index 050d278..d3030d8 100644 --- a/app/medications/[medicationId].tsx +++ b/app/medications/[medicationId].tsx @@ -1,3 +1,4 @@ +import { ExpiryDatePickerModal } from '@/components/medications/ExpiryDatePickerModal'; import { ThemedText } from '@/components/ThemedText'; import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet'; import { HeaderBar } from '@/components/ui/HeaderBar'; @@ -12,10 +13,11 @@ import { useColorScheme } from '@/hooks/useColorScheme'; import { useCosUpload } from '@/hooks/useCosUpload'; import { useI18n } from '@/hooks/useI18n'; import { useVipService } from '@/hooks/useVipService'; -import { medicationNotificationService } from '@/services/medicationNotifications'; import { analyzeMedicationV2, + confirmMedicationRecognition, getMedicationById, + getMedicationRecognitionStatus, getMedicationRecords, } from '@/services/medications'; import { @@ -24,7 +26,12 @@ import { selectMedications, updateMedicationAction, } from '@/store/medicationsSlice'; -import type { Medication, MedicationAiAnalysisV2, MedicationForm } from '@/types/medication'; +import type { + Medication, + MedicationAiAnalysisV2, + MedicationAiRecognitionResult, + MedicationForm, +} from '@/types/medication'; import { Ionicons } from '@expo/vector-icons'; import { Picker } from '@react-native-picker/picker'; import Voice from '@react-native-voice/voice'; @@ -63,10 +70,12 @@ type RecordsSummary = { export default function MedicationDetailScreen() { const { t } = useI18n(); - const params = useLocalSearchParams<{ medicationId?: string }>(); + const params = useLocalSearchParams<{ medicationId?: string; aiTaskId?: string; cover?: string }>(); const medicationId = Array.isArray(params.medicationId) ? params.medicationId[0] : params.medicationId; + const aiTaskId = Array.isArray(params.aiTaskId) ? params.aiTaskId[0] : params.aiTaskId; + const coverFromParams = Array.isArray(params.cover) ? params.cover[0] : params.cover; const dispatch = useAppDispatch(); const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors; const colors = Colors[scheme]; @@ -79,9 +88,10 @@ export default function MedicationDetailScreen() { const medications = useAppSelector(selectMedications); const medicationFromStore = medications.find((item) => item.id === medicationId); + const isAiDraft = Boolean(aiTaskId); const [medication, setMedication] = useState(medicationFromStore ?? null); - const [loading, setLoading] = useState(!medicationFromStore); + const [loading, setLoading] = useState(isAiDraft ? true : !medicationFromStore); const [summary, setSummary] = useState({ takenCount: 0, startedDays: null, @@ -105,12 +115,47 @@ export default function MedicationDetailScreen() { const [deactivateLoading, setDeactivateLoading] = useState(false); const [showImagePreview, setShowImagePreview] = useState(false); const [photoPreview, setPhotoPreview] = useState(null); + + const buildAiDraftMedication = useCallback( + (result: MedicationAiRecognitionResult): Medication => { + const timeList = + result.medicationTimes && result.medicationTimes.length + ? result.medicationTimes + : Array.from({ length: result.timesPerDay ?? 1 }, (_, idx) => { + const base = ['08:00', '12:30', '18:30', '22:00']; + return base[idx] ?? base[0]; + }); + + return { + id: 'ai-draft', + userId: '', + name: result.name || 'AI 识别药物', + photoUrl: result.photoUrl || coverFromParams || undefined, + form: result.form || 'other', + dosageValue: result.dosageValue ?? 1, + dosageUnit: result.dosageUnit || '次', + timesPerDay: result.timesPerDay ?? Math.max(timeList.length, 1), + medicationTimes: timeList, + startDate: result.startDate || new Date().toISOString(), + endDate: result.endDate ?? null, + repeatPattern: 'daily', + note: result.note || '', + aiAnalysis: result ? JSON.stringify(result) : undefined, + isActive: true, + deleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + }, + [coverFromParams] + ); // AI 分析相关状态 const [aiAnalysisLoading, setAiAnalysisLoading] = useState(false); const [aiAnalysisResult, setAiAnalysisResult] = useState(null); const [aiAnalysisError, setAiAnalysisError] = useState(null); const [aiAnalysisLocked, setAiAnalysisLocked] = useState(false); + const [aiDraftSaving, setAiDraftSaving] = useState(false); // 剂量选择相关状态 const [dosagePickerVisible, setDosagePickerVisible] = useState(false); @@ -127,6 +172,10 @@ export default function MedicationDetailScreen() { medicationFromStore?.form ?? 'capsule' ); + // 有效期选择相关状态 + const [expiryDatePickerVisible, setExpiryDatePickerVisible] = useState(false); + const [expiryDatePickerValue, setExpiryDatePickerValue] = useState(new Date()); + // ScrollView 引用,用于滚动到底部 const scrollViewRef = React.useRef(null); @@ -184,6 +233,13 @@ export default function MedicationDetailScreen() { hasMedicationFromStore: !!medicationFromStore, deleteLoading }); + + if (isAiDraft) { + return () => { + isMounted = false; + abortController.abort(); + }; + } // 如果正在删除操作中,不执行任何操作 if (deleteLoading) { @@ -246,9 +302,57 @@ export default function MedicationDetailScreen() { }; }, [medicationId, medicationFromStore, deleteLoading]); + useEffect(() => { + let cancelled = false; + if (!aiTaskId) return; + + const hydrateFromAi = async () => { + try { + setLoading(true); + const data = await getMedicationRecognitionStatus(aiTaskId); + if (cancelled) return; + + if (data.status !== 'completed' || !data.result) { + setError('AI 识别结果暂不可用'); + return; + } + + const draft = buildAiDraftMedication(data.result); + setMedication(draft); + setAiAnalysisResult({ + suitableFor: data.result.suitableFor ?? [], + unsuitableFor: data.result.unsuitableFor ?? [], + mainIngredients: data.result.mainIngredients ?? [], + mainUsage: data.result.mainUsage ?? '', + sideEffects: data.result.sideEffects ?? [], + storageAdvice: data.result.storageAdvice ?? [], + healthAdvice: data.result.healthAdvice ?? [], + }); + setSummary({ takenCount: 0, startedDays: null }); + setSummaryLoading(false); + setError(null); + setAiAnalysisLocked(false); + } catch (err) { + if (cancelled) return; + console.error('[MEDICATION_DETAIL] 加载 AI 草稿失败', err); + setError('识别结果加载失败,请返回重试'); + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + hydrateFromAi(); + + return () => { + cancelled = true; + }; + }, [aiTaskId, buildAiDraftMedication]); + useEffect(() => { let isMounted = true; - if (!medicationId) { + if (!medicationId || isAiDraft) { return () => { isMounted = false; }; @@ -403,7 +507,7 @@ export default function MedicationDetailScreen() { }, [dictationActive]); const handleToggleMedication = async (nextValue: boolean) => { - if (!medication || updatePending) return; + if (!medication || updatePending || isAiDraft) return; // 如果是关闭激活状态,显示确认弹窗 if (!nextValue) { @@ -422,16 +526,6 @@ export default function MedicationDetailScreen() { ).unwrap(); setMedication(updated); - // 重新安排药品通知 - try { - if (nextValue) { - // 如果激活了药品,安排通知 - await medicationNotificationService.scheduleMedicationNotifications(updated); - } - } catch (error) { - console.error('[MEDICATION] 处理药品通知失败:', error); - // 不影响药品状态切换的成功流程,只记录错误 - } } catch (err) { console.error('切换药品状态失败', err); Alert.alert(t('medications.detail.toggleError.title'), t('medications.detail.toggleError.message')); @@ -441,7 +535,7 @@ export default function MedicationDetailScreen() { }; const handleDeactivateMedication = useCallback(async () => { - if (!medication || deactivateLoading) return; + if (!medication || deactivateLoading || isAiDraft) return; try { setDeactivateLoading(true); @@ -455,13 +549,6 @@ export default function MedicationDetailScreen() { ).unwrap(); setMedication(updated); - // 取消该药品的通知 - try { - await medicationNotificationService.cancelMedicationNotifications(updated.id); - } catch (error) { - console.error('[MEDICATION] 取消药品通知失败:', error); - // 不影响药品状态切换的成功流程,只记录错误 - } } catch (error) { console.error('停用药物失败', error); Alert.alert(t('medications.detail.deactivate.error.title'), t('medications.detail.deactivate.error.message')); @@ -492,6 +579,25 @@ export default function MedicationDetailScreen() { } }, [medication, t]); + // 计算有效期显示 + const expiryDateLabel = useMemo(() => { + if (!medication?.expiryDate) return '未设置'; + + const expiry = dayjs(medication.expiryDate); + const today = dayjs(); + const daysUntilExpiry = expiry.diff(today, 'day'); + + if (daysUntilExpiry < 0) { + return `${expiry.format('YYYY年M月D日')} (已过期)`; + } else if (daysUntilExpiry === 0) { + return `${expiry.format('YYYY年M月D日')} (今天到期)`; + } else if (daysUntilExpiry <= 30) { + return `${expiry.format('YYYY年M月D日')} (${daysUntilExpiry}天后到期)`; + } else { + return expiry.format('YYYY年M月D日'); + } + }, [medication?.expiryDate]); + const reminderTimes = medication?.medicationTimes?.length ? medication.medicationTimes.join('、') : t('medications.manage.reminderNotSet'); @@ -539,18 +645,23 @@ export default function MedicationDetailScreen() { const trimmed = nameDraft.trim(); if (!trimmed) { Alert.alert( - t('medications.detail.nameEdit.errorTitle', { defaultValue: '提示' }), - t('medications.detail.nameEdit.errorEmpty', { defaultValue: '药物名称不能为空' }) + '提示', + '药物名称不能为空' ); return; } if (Array.from(trimmed).length > 10) { Alert.alert( - t('medications.detail.nameEdit.errorTitle', { defaultValue: '提示' }), - t('medications.detail.nameEdit.errorTooLong', { defaultValue: '药物名称不能超过10个字' }) + '提示', + '药物名称不能超过10个字' ); return; } + if (isAiDraft) { + setMedication((prev) => (prev ? { ...prev, name: trimmed } : prev)); + setNameModalVisible(false); + return; + } setNameSaving(true); try { const updated = await dispatch( @@ -564,17 +675,22 @@ export default function MedicationDetailScreen() { } catch (err) { console.error('更新药物名称失败', err); Alert.alert( - t('medications.detail.nameEdit.errorTitle', { defaultValue: '提示' }), - t('medications.detail.nameEdit.saveError', { defaultValue: '名称更新失败,请稍后再试' }) + '提示', + '名称更新失败,请稍后再试' ); } finally { setNameSaving(false); } - }, [dispatch, medication, nameDraft, nameSaving, t]); + }, [dispatch, isAiDraft, medication, nameDraft, nameSaving, t]); const handleSaveNote = useCallback(async () => { if (!medication) return; const trimmed = noteDraft.trim(); + if (isAiDraft) { + setMedication((prev) => (prev ? { ...prev, note: trimmed } : prev)); + closeNoteModal(); + return; + } setNoteSaving(true); try { const updated = await dispatch( @@ -591,7 +707,7 @@ export default function MedicationDetailScreen() { } finally { setNoteSaving(false); } - }, [closeNoteModal, dispatch, medication, noteDraft]); + }, [closeNoteModal, dispatch, isAiDraft, medication, noteDraft]); useEffect(() => { if (serviceInfo.canUseService) { @@ -620,14 +736,6 @@ export default function MedicationDetailScreen() { 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(); @@ -650,11 +758,11 @@ export default function MedicationDetailScreen() { if (!medication || uploading) return; Alert.alert( - t('medications.detail.photo.selectTitle', { defaultValue: '选择图片' }), - t('medications.detail.photo.selectMessage', { defaultValue: '请选择图片来源' }), + t('medications.add.photo.selectTitle'), + t('medications.add.photo.selectMessage'), [ { - text: t('medications.detail.photo.takePhoto', { defaultValue: '拍照' }), + text: t('medications.add.photo.camera'), onPress: async () => { try { const permission = await ImagePicker.requestCameraPermissionsAsync(); @@ -689,6 +797,12 @@ export default function MedicationDetailScreen() { { prefix: 'images/medications' } ); + if (isAiDraft) { + setMedication((prev) => (prev ? { ...prev, photoUrl: url } : prev)); + setPhotoPreview(null); + return; + } + // 上传成功后更新药物信息 const updated = await dispatch( updateMedicationAction({ @@ -716,7 +830,7 @@ export default function MedicationDetailScreen() { }, }, { - text: t('medications.detail.photo.chooseFromLibrary', { defaultValue: '从相册选择' }), + text: t('medications.add.photo.album'), onPress: async () => { try { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); @@ -750,6 +864,12 @@ export default function MedicationDetailScreen() { { prefix: 'images/medications' } ); + if (isAiDraft) { + setMedication((prev) => (prev ? { ...prev, photoUrl: url } : prev)); + setPhotoPreview(null); + return; + } + // 上传成功后更新药物信息 const updated = await dispatch( updateMedicationAction({ @@ -777,13 +897,13 @@ export default function MedicationDetailScreen() { }, }, { - text: t('medications.detail.photo.cancel', { defaultValue: '取消' }), + text: t('medications.add.photo.cancel'), style: 'cancel', }, ], { cancelable: true } ); - }, [medication, uploading, upload, dispatch, t]); + }, [dispatch, isAiDraft, medication, t, upload, uploading]); const handleStartDatePress = useCallback(() => { if (!medication) return; @@ -792,22 +912,16 @@ export default function MedicationDetailScreen() { let message; if (medication.endDate) { const endDate = dayjs(medication.endDate).format('YYYY年M月D日'); - message = t('medications.detail.plan.periodMessage', { - startDate, - endDateInfo: t('medications.detail.plan.periodMessage', { endDate }) - }); + message = `从 ${startDate} 至 ${endDate}`; } else { - message = t('medications.detail.plan.periodMessage', { - startDate, - endDateInfo: t('medications.detail.plan.longTermPlan') - }); + message = `从 ${startDate} 至长期`; } - Alert.alert(t('medications.detail.sections.plan'), message); + Alert.alert('服药周期', message); }, [medication, t]); const handleTimePress = useCallback(() => { - Alert.alert(t('medications.detail.plan.time'), t('medications.detail.plan.timeMessage', { times: reminderTimes })); + Alert.alert('服药时间', `每日提醒时间:${reminderTimes}`); }, [reminderTimes, t]); const handleDosagePress = useCallback(() => { @@ -822,7 +936,18 @@ export default function MedicationDetailScreen() { const handleFrequencyPress = useCallback(() => { if (!medication) return; - // 跳转到独立的频率编辑页面 + + // AI 草稿模式:显示提示,暂不支持编辑频率 + if (isAiDraft) { + Alert.alert( + '提示', + '请先保存药物信息后,再编辑服药频率', + [{ text: '知道了', style: 'default' }] + ); + return; + } + + // 正常模式:跳转到独立的频率编辑页面 router.push({ pathname: ROUTES.MEDICATION_EDIT_FREQUENCY, params: { @@ -833,7 +958,43 @@ export default function MedicationDetailScreen() { medicationTimes: medication.medicationTimes.join(','), }, }); - }, [medication, router]); + }, [medication, router, isAiDraft]); + + const handleExpiryDatePress = useCallback(() => { + if (!medication) return; + setExpiryDatePickerValue(medication.expiryDate ? new Date(medication.expiryDate) : new Date()); + setExpiryDatePickerVisible(true); + }, [medication]); + + const handleExpiryDateConfirm = useCallback(async (date: Date) => { + if (!medication || updatePending) return; + + if (isAiDraft) { + setMedication((prev) => + prev + ? { ...prev, expiryDate: dayjs(date).endOf('day').toISOString() } + : prev + ); + return; + } + + try { + setUpdatePending(true); + const updated = await dispatch( + updateMedicationAction({ + id: medication.id, + expiryDate: dayjs(date).endOf('day').toISOString(), + }) + ).unwrap(); + setMedication(updated); + + } catch (err) { + console.error('更新有效期失败', err); + Alert.alert('更新失败', '有效期更新失败,请稍后重试'); + } finally { + setUpdatePending(false); + } + }, [dispatch, isAiDraft, medication, updatePending]); const renderAdviceCard = useCallback( ( @@ -878,6 +1039,15 @@ export default function MedicationDetailScreen() { if (dosageValuePicker === medication.dosageValue && dosageUnitPicker === medication.dosageUnit) { return; } + + if (isAiDraft) { + setMedication((prev) => + prev + ? { ...prev, dosageValue: dosageValuePicker, dosageUnit: dosageUnitPicker } + : prev + ); + return; + } try { setUpdatePending(true); @@ -890,20 +1060,13 @@ 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(t('medications.detail.updateErrors.dosage'), t('medications.detail.updateErrors.dosageMessage')); } finally { setUpdatePending(false); } - }, [dispatch, dosageUnitPicker, dosageValuePicker, medication, updatePending]); + }, [dispatch, dosageUnitPicker, dosageValuePicker, isAiDraft, medication, updatePending]); const confirmFormPicker = useCallback(async () => { if (!medication || updatePending) return; @@ -914,6 +1077,11 @@ export default function MedicationDetailScreen() { if (formPicker === medication.form) { return; } + + if (isAiDraft) { + setMedication((prev) => (prev ? { ...prev, form: formPicker } : prev)); + return; + } try { setUpdatePending(true); @@ -925,24 +1093,17 @@ 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(t('medications.detail.updateErrors.form'), t('medications.detail.updateErrors.formMessage')); } finally { setUpdatePending(false); } - }, [dispatch, formPicker, medication, updatePending]); + }, [dispatch, formPicker, isAiDraft, medication, updatePending]); // AI 分析处理函数 const handleAiAnalysis = useCallback(async () => { - if (!medication || aiAnalysisLoading) return; + if (!medication || aiAnalysisLoading || isAiDraft) return; // 1. 先验证用户是否登录 const isLoggedIn = await ensureLoggedIn(); @@ -1001,7 +1162,34 @@ export default function MedicationDetailScreen() { } finally { setAiAnalysisLoading(false); } - }, [aiAnalysisLoading, checkServiceAccess, ensureLoggedIn, medication, openMembershipModal, t]); + }, [aiAnalysisLoading, checkServiceAccess, ensureLoggedIn, isAiDraft, medication, openMembershipModal, t]); + + const handleAiDraftSave = useCallback(async () => { + if (!aiTaskId || !medication || aiDraftSaving) return; + + try { + setAiDraftSaving(true); + const created = await confirmMedicationRecognition(aiTaskId, { + name: medication.name, + timesPerDay: medication.timesPerDay, + medicationTimes: medication.medicationTimes, + startDate: medication.startDate, + endDate: medication.endDate ?? undefined, + note: medication.note, + }); + + await dispatch(fetchMedications()); + router.replace({ + pathname: '/medications/[medicationId]', + params: { medicationId: created.id }, + }); + } catch (err: any) { + console.error('[MEDICATION_DETAIL] AI 草稿保存失败', err); + Alert.alert('保存失败', err?.message || '请稍后再试'); + } finally { + setAiDraftSaving(false); + } + }, [aiDraftSaving, aiTaskId, dispatch, medication, router]); if (!medicationId) { return ( @@ -1109,7 +1297,7 @@ export default function MedicationDetailScreen() { - {t('medications.detail.photo.uploading', { defaultValue: '上传中...' })} + 上传中... )} @@ -1153,7 +1341,7 @@ export default function MedicationDetailScreen() { icon="calendar-outline" colors={colors} clickable={false} - onPress={handleStartDatePress} + onPress={isAiDraft ? undefined : handleStartDatePress} /> + + + + - - - - {t('medications.detail.plan.frequency')} - - - {frequencyLabel} - - -
@@ -1187,7 +1379,7 @@ export default function MedicationDetailScreen() { value={dosageLabel} icon="medkit-outline" colors={colors} - clickable={true} + clickable={!isAiDraft} onPress={handleDosagePress} /> @@ -1427,68 +1619,92 @@ export default function MedicationDetailScreen() { }, ]} > - - {/* AI 分析按钮 */} - {!hasAiAnalysis && ( + {isAiDraft ? ( + router.replace('/medications/ai-camera')} + > + 重新拍摄 + + + {aiDraftSaving ? ( + + ) : ( + 保存并创建 + )} + + + ) : ( + + {/* AI 分析按钮 */} + {!hasAiAnalysis && ( + + {isLiquidGlassAvailable() ? ( + + {aiAnalysisLoading ? ( + + ) : ( + + )} + + {aiActionLabel} + + + ) : ( + + {aiAnalysisLoading ? ( + + ) : ( + + )} + + {aiActionLabel} + + + )} + + )} + + {/* 删除按钮 */} + setDeleteSheetVisible(true)} > {isLiquidGlassAvailable() ? ( - {aiAnalysisLoading ? ( - - ) : ( - - )} - - {aiActionLabel} - + ) : ( - - {aiAnalysisLoading ? ( - - ) : ( - - )} - - {aiActionLabel} - + + )} - )} - - {/* 删除按钮 */} - setDeleteSheetVisible(true)} - > - {isLiquidGlassAvailable() ? ( - - - - ) : ( - - - - )} - - + + )} ) : null} @@ -1510,7 +1726,7 @@ export default function MedicationDetailScreen() { - {t('medications.detail.nameEdit.title', { defaultValue: '编辑药物名称' })} + 编辑药物名称 @@ -1528,7 +1744,7 @@ export default function MedicationDetailScreen() { ) : ( - {t('medications.detail.nameEdit.saveButton', { defaultValue: '保存' })} + 保存 )} @@ -1790,9 +2006,19 @@ export default function MedicationDetailScreen() { + - {medication ? ( + {/* 有效期选择器 */} + setExpiryDatePickerVisible(false)} + onConfirm={handleExpiryDateConfirm} + isAiDraft={isAiDraft} + /> + + {medication && !isAiDraft ? ( setDeleteSheetVisible(false)} @@ -1806,7 +2032,7 @@ export default function MedicationDetailScreen() { /> ) : null} - {medication ? ( + {medication && !isAiDraft ? ( setDeactivateSheetVisible(false)} @@ -2243,6 +2469,35 @@ const styles = StyleSheet.create({ fontWeight: '700', color: '#fff', }, + primaryFooterBtn: { + flex: 1, + height: 56, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#0f172a', + shadowOpacity: 0.15, + shadowRadius: 10, + shadowOffset: { width: 0, height: 8 }, + }, + primaryFooterText: { + fontSize: 17, + fontWeight: '700', + }, + secondaryFooterBtn: { + height: 56, + paddingHorizontal: 18, + borderRadius: 16, + borderWidth: 1, + borderColor: '#E2E8F0', + alignItems: 'center', + justifyContent: 'center', + }, + secondaryFooterText: { + fontSize: 16, + fontWeight: '700', + color: '#0f172a', + }, // AI 分析卡片样式 aiCardContainer: { borderRadius: 26, diff --git a/app/medications/add-medication.tsx b/app/medications/add-medication.tsx index e577198..af46784 100644 --- a/app/medications/add-medication.tsx +++ b/app/medications/add-medication.tsx @@ -7,7 +7,6 @@ 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'; @@ -151,10 +150,13 @@ export default function AddMedicationScreen() { const [timesPickerValue, setTimesPickerValue] = useState(1); const [startDate, setStartDate] = useState(new Date()); const [endDate, setEndDate] = useState(null); + const [expiryDate, setExpiryDate] = useState(null); const [datePickerVisible, setDatePickerVisible] = useState(false); const [datePickerValue, setDatePickerValue] = useState(new Date()); const [endDatePickerVisible, setEndDatePickerVisible] = useState(false); const [endDatePickerValue, setEndDatePickerValue] = useState(new Date()); + const [expiryDatePickerVisible, setExpiryDatePickerVisible] = useState(false); + const [expiryDatePickerValue, setExpiryDatePickerValue] = useState(new Date()); const [datePickerMode, setDatePickerMode] = useState<'start' | 'end'>('start'); const [medicationTimes, setMedicationTimes] = useState([DEFAULT_TIME_PRESETS[0]]); const [timePickerVisible, setTimePickerVisible] = useState(false); @@ -319,6 +321,7 @@ export default function AddMedicationScreen() { medicationTimes: medicationTimes, startDate: dayjs(startDate).startOf('day').toISOString(), // ISO 8601 格式 endDate: endDate ? dayjs(endDate).endOf('day').toISOString() : undefined, // 如果有结束日期,设置为当天结束时间 + expiryDate: expiryDate ? dayjs(expiryDate).endOf('day').toISOString() : undefined, // 如果有有效期,设置为当天结束时间 repeatPattern: 'daily' as RepeatPattern, note: note.trim() || undefined, }; @@ -333,16 +336,6 @@ 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( '添加成功', @@ -531,6 +524,11 @@ export default function AddMedicationScreen() { setEndDatePickerVisible(true); }, [endDate]); + const openExpiryDatePicker = useCallback(() => { + setExpiryDatePickerValue(expiryDate || new Date()); + setExpiryDatePickerVisible(true); + }, [expiryDate]); + const confirmStartDate = useCallback((date: Date) => { // 验证开始日期不能早于今天 const today = new Date(); @@ -563,6 +561,22 @@ export default function AddMedicationScreen() { setEndDatePickerVisible(false); }, [startDate]); + const confirmExpiryDate = useCallback((date: Date) => { + // 验证有效期不能早于今天 + const today = new Date(); + today.setHours(0, 0, 0, 0); + const selectedDate = new Date(date); + selectedDate.setHours(0, 0, 0, 0); + + if (selectedDate < today) { + Alert.alert('日期无效', '有效期不能早于今天'); + return; + } + + setExpiryDate(date); + setExpiryDatePickerVisible(false); + }, []); + const openTimePicker = useCallback( (index?: number) => { try { @@ -872,6 +886,32 @@ export default function AddMedicationScreen() { + + + + 药品有效期 + + + + + 有效期至 + + {expiryDate ? dayjs(expiryDate).format('YYYY/MM/DD') : '未设置'} + + + + + ); case 3: @@ -1166,6 +1206,51 @@ export default function AddMedicationScreen() { + setExpiryDatePickerVisible(false)} + > + setExpiryDatePickerVisible(false)} /> + + 选择药品有效期 + { + if (Platform.OS === 'ios') { + if (date) setExpiryDatePickerValue(date); + } else { + if (event.type === 'set' && date) { + confirmExpiryDate(date); + } else { + setExpiryDatePickerVisible(false); + } + } + }} + /> + {Platform.OS === 'ios' && ( + + setExpiryDatePickerVisible(false)} + style={[styles.modalBtn, { borderColor: softBorderColor }]} + > + 取消 + + confirmExpiryDate(expiryDatePickerValue)} + style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colors.primary }]} + > + 确定 + + + )} + + + (null); + const [facing, setFacing] = useState<'back' | 'front'>('back'); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [shots, setShots] = useState>({ + front: null, + side: null, + aux: null, + }); + const [creatingTask, setCreatingTask] = useState(false); + const [showGuideModal, setShowGuideModal] = useState(false); + + // 首次进入时显示引导弹窗 + useEffect(() => { + const hasSeenGuide = false; // 每次都显示,如需持久化可使用 AsyncStorage + if (!hasSeenGuide) { + setShowGuideModal(true); + } + }, []); + + const currentStep = captureSteps[currentStepIndex]; + const coverPreview = shots[currentStep.key]?.uri ?? shots.front?.uri; + const allRequiredCaptured = Boolean(shots.front && shots.side); + + const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]); + + const handleToggleCamera = () => { + setFacing((prev) => (prev === 'back' ? 'front' : 'back')); + }; + + const handlePickFromAlbum = async () => { + try { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: true, + quality: 0.9, + }); + + if (!result.canceled && result.assets?.length) { + const asset = result.assets[0]; + setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } })); + + // 拍摄完成后自动进入下一步(如果还有下一步) + if (currentStepIndex < captureSteps.length - 1) { + setTimeout(() => { + goNextStep(); + }, 300); + } + } + } catch (error) { + console.error('[MEDICATION_AI] pick image failed', error); + Alert.alert('选择失败', '请重试或更换图片'); + } + }; + + const handleTakePicture = async () => { + if (!cameraRef.current) return; + try { + const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 }); + if (photo?.uri) { + setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } })); + + // 拍摄完成后自动进入下一步(如果还有下一步) + if (currentStepIndex < captureSteps.length - 1) { + setTimeout(() => { + goNextStep(); + }, 300); + } + } + } catch (error) { + console.error('[MEDICATION_AI] take picture failed', error); + Alert.alert('拍摄失败', '请重试'); + } + }; + + const goNextStep = () => { + if (currentStepIndex < captureSteps.length - 1) { + setCurrentStepIndex((prev) => prev + 1); + } + }; + + const handleStartRecognition = async () => { + // 检查必需照片是否完成 + if (!allRequiredCaptured) { + Alert.alert('照片不足', '请至少完成正面和背面拍摄'); + return; + } + + await startRecognition(); + }; + + const startRecognition = async () => { + if (!shots.front || !shots.side) return; + const isLoggedIn = await ensureLoggedIn(); + if (!isLoggedIn) return; + + try { + setCreatingTask(true); + const [frontUpload, sideUpload, auxUpload] = await Promise.all([ + upload({ uri: shots.front.uri, name: `front-${Date.now()}.jpg`, type: 'image/jpeg' }), + upload({ uri: shots.side.uri, name: `side-${Date.now()}.jpg`, type: 'image/jpeg' }), + shots.aux ? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' }) : Promise.resolve(null), + ]); + + const task = await createMedicationRecognitionTask({ + frontImageUrl: frontUpload.url, + sideImageUrl: sideUpload.url, + auxiliaryImageUrl: auxUpload?.url, + }); + + router.replace({ + pathname: '/medications/ai-progress', + params: { + taskId: task.taskId, + cover: frontUpload.url, + }, + }); + } catch (error: any) { + console.error('[MEDICATION_AI] recognize failed', error); + Alert.alert('创建任务失败', error?.message || '请检查网络后重试'); + } finally { + setCreatingTask(false); + } + }; + + if (!permission) { + return null; + } + + if (!permission.granted) { + return ( + + router.back()} transparent /> + + 需要相机权限 + 授权后即可快速拍摄药品包装,自动识别信息 + + 授权访问相机 + + + + ); + } + + return ( + <> + {/* 引导说明弹窗 - 移到最外层 */} + setShowGuideModal(false)} + /> + + + + router.back()} + transparent + right={ + setShowGuideModal(true)} + activeOpacity={0.7} + accessibilityLabel="查看拍摄说明" + > + {isLiquidGlassAvailable() ? ( + + + + ) : ( + + + + )} + + } + /> + + + + + {stepTitle} + + {currentStep.title} + {currentStep.subtitle} + + + + + + + {coverPreview ? ( + + + + ) : null} + + + + + {captureSteps.map((step, index) => { + const active = step.key === currentStep.key; + const shot = shots[step.key]; + return ( + setCurrentStepIndex(index)} + activeOpacity={0.7} + style={[styles.shotCard, active && styles.shotCardActive]} + > + + {step.title} + {!step.mandatory ? '(可选)' : ''} + + {shot ? ( + + ) : ( + + 未拍摄 + + )} + + ); + })} + + + + + + {isLiquidGlassAvailable() ? ( + + + 从相册 + + ) : ( + + + 从相册 + + )} + + + + {isLiquidGlassAvailable() ? ( + + + + + + ) : ( + + + + + + )} + + + + {isLiquidGlassAvailable() ? ( + + + 翻转 + + ) : ( + + + 翻转 + + )} + + + + {/* 只要正面和背面都有照片就显示识别按钮 */} + {allRequiredCaptured && ( + + {creatingTask || uploading ? ( + + ) : ( + + 开始识别 + + )} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + topMeta: { + paddingHorizontal: 20, + paddingTop: 12, + gap: 6, + }, + metaBadge: { + alignSelf: 'flex-start', + backgroundColor: '#e0f2fe', + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 14, + }, + metaBadgeText: { + color: '#0369a1', + fontWeight: '700', + fontSize: 12, + }, + metaTitle: { + fontSize: 22, + fontWeight: '700', + color: '#0f172a', + }, + metaSubtitle: { + fontSize: 14, + color: '#475569', + }, + cameraCard: { + marginHorizontal: 20, + marginTop: 12, + borderRadius: 24, + overflow: 'hidden', + shadowColor: '#0f172a', + shadowOpacity: 0.12, + shadowRadius: 18, + shadowOffset: { width: 0, height: 10 }, + }, + cameraFrame: { + borderRadius: 24, + overflow: 'hidden', + backgroundColor: '#0b172a', + height: 360, + }, + cameraView: { + flex: 1, + }, + cameraOverlay: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: 80, + }, + previewBadge: { + position: 'absolute', + right: 12, + bottom: 12, + width: 90, + height: 90, + borderRadius: 12, + overflow: 'hidden', + borderWidth: 2, + borderColor: '#fff', + }, + previewImage: { + width: '100%', + height: '100%', + }, + shotsRow: { + flexDirection: 'row', + paddingHorizontal: 20, + paddingTop: 12, + gap: 10, + }, + shotCard: { + flex: 1, + borderRadius: 14, + backgroundColor: '#f8fafc', + padding: 10, + gap: 8, + borderWidth: 1, + borderColor: '#e2e8f0', + }, + shotCardActive: { + borderColor: '#38bdf8', + backgroundColor: '#ecfeff', + }, + shotLabel: { + fontSize: 12, + color: '#475569', + fontWeight: '600', + }, + shotLabelActive: { + color: '#0ea5e9', + }, + shotThumb: { + width: '100%', + height: 70, + borderRadius: 12, + }, + shotPlaceholder: { + height: 70, + borderRadius: 12, + backgroundColor: '#e2e8f0', + alignItems: 'center', + justifyContent: 'center', + }, + shotPlaceholderText: { + color: '#94a3b8', + fontSize: 12, + }, + bottomBar: { + paddingHorizontal: 20, + paddingTop: 12, + gap: 10, + }, + bottomActions: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + captureBtn: { + width: 86, + height: 86, + borderRadius: 43, + justifyContent: 'center', + alignItems: 'center', + overflow: 'hidden', + shadowColor: '#0ea5e9', + shadowOpacity: 0.25, + shadowRadius: 16, + shadowOffset: { width: 0, height: 8 }, + }, + fallbackCaptureBtn: { + backgroundColor: 'rgba(255, 255, 255, 0.95)', + borderWidth: 3, + borderColor: 'rgba(14, 165, 233, 0.2)', + }, + captureOuterRing: { + width: 76, + height: 76, + borderRadius: 38, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + captureInner: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#fff', + shadowColor: '#0ea5e9', + shadowOpacity: 0.4, + shadowRadius: 8, + shadowOffset: { width: 0, height: 2 }, + }, + secondaryBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 16, + overflow: 'hidden', + shadowColor: '#0f172a', + shadowOpacity: 0.08, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + }, + fallbackSecondaryBtn: { + backgroundColor: 'rgba(255, 255, 255, 0.9)', + borderWidth: 1, + borderColor: 'rgba(15, 23, 42, 0.1)', + }, + secondaryBtnText: { + color: '#0f172a', + fontWeight: '600', + fontSize: 14, + }, + primaryCta: { + marginTop: 6, + borderRadius: 16, + paddingVertical: 14, + alignItems: 'center', + shadowColor: '#0f172a', + shadowOpacity: 0.12, + shadowRadius: 10, + shadowOffset: { width: 0, height: 6 }, + }, + primaryText: { + fontSize: 16, + fontWeight: '700', + }, + skipBtn: { + alignSelf: 'center', + paddingVertical: 6, + paddingHorizontal: 12, + }, + skipText: { + color: '#475569', + fontSize: 13, + }, + infoButton: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + fallbackInfoButton: { + backgroundColor: 'rgba(255, 255, 255, 0.9)', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.3)', + }, + permissionCard: { + marginHorizontal: 24, + borderRadius: 18, + padding: 20, + backgroundColor: '#fff', + shadowColor: '#0f172a', + shadowOpacity: 0.08, + shadowRadius: 12, + shadowOffset: { width: 0, height: 10 }, + alignItems: 'center', + gap: 10, + }, + permissionTitle: { + fontSize: 18, + fontWeight: '700', + color: '#0f172a', + }, + permissionTip: { + fontSize: 14, + color: '#475569', + textAlign: 'center', + lineHeight: 20, + }, + permissionBtn: { + marginTop: 6, + borderRadius: 14, + paddingHorizontal: 18, + paddingVertical: 12, + }, + permissionBtnText: { + color: '#fff', + fontWeight: '700', + }, +}); diff --git a/app/medications/ai-progress.tsx b/app/medications/ai-progress.tsx new file mode 100644 index 0000000..632e31d --- /dev/null +++ b/app/medications/ai-progress.tsx @@ -0,0 +1,514 @@ +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { Colors } from '@/constants/Colors'; +import { getMedicationRecognitionStatus } from '@/services/medications'; +import { MedicationRecognitionTask } from '@/types/medication'; +import { Ionicons } from '@expo/vector-icons'; +import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import { router, useLocalSearchParams } from 'expo-router'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { ActivityIndicator, Animated, Dimensions, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); + +const STATUS_STEPS: { key: MedicationRecognitionTask['status']; label: string }[] = [ + { key: 'analyzing_product', label: '正在进行产品分析...' }, + { key: 'analyzing_suitability', label: '正在检测适宜人群...' }, + { key: 'analyzing_ingredients', label: '正在评估成分信息...' }, + { key: 'analyzing_effects', label: '正在生成安全建议...' }, +]; + +export default function MedicationAiProgressScreen() { + const { taskId, cover } = useLocalSearchParams<{ taskId?: string; cover?: string }>(); + const insets = useSafeAreaInsets(); + const [task, setTask] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showErrorModal, setShowErrorModal] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const navigatingRef = useRef(false); + const pollingTimerRef = useRef | null>(null); + + // 动画值:上下浮动和透明度 + const floatAnim = useRef(new Animated.Value(0)).current; + const opacityAnim = useRef(new Animated.Value(0.3)).current; + + const currentStepIndex = useMemo(() => { + if (!task) return 0; + const idx = STATUS_STEPS.findIndex((step) => step.key === task.status); + if (idx >= 0) return idx; + if (task.status === 'completed') return STATUS_STEPS.length; + return 0; + }, [task]); + + const fetchStatus = async () => { + if (!taskId || navigatingRef.current) return; + try { + const data = await getMedicationRecognitionStatus(taskId as string); + setTask(data); + setError(null); + + // 识别成功,跳转到详情页 + if (data.status === 'completed' && data.result && !navigatingRef.current) { + navigatingRef.current = true; + // 清除轮询 + if (pollingTimerRef.current) { + clearInterval(pollingTimerRef.current); + pollingTimerRef.current = null; + } + router.replace({ + pathname: '/medications/[medicationId]', + params: { + medicationId: 'ai-draft', + aiTaskId: data.taskId, + cover: (cover as string) || data.result.photoUrl || '', + }, + }); + } + + // 识别失败,停止轮询并显示错误弹窗 + if (data.status === 'failed' && !navigatingRef.current) { + navigatingRef.current = true; + // 清除轮询 + if (pollingTimerRef.current) { + clearInterval(pollingTimerRef.current); + pollingTimerRef.current = null; + } + // 显示错误提示弹窗 + setErrorMessage(data.errorMessage || '识别失败,请重新拍摄'); + setShowErrorModal(true); + } + } catch (err: any) { + console.error('[MEDICATION_AI] status failed', err); + setError(err?.message || '查询失败,请稍后再试'); + } finally { + setLoading(false); + } + }; + + // 处理重新拍摄 + const handleRetry = () => { + setShowErrorModal(false); + router.back(); + }; + + useEffect(() => { + fetchStatus(); + pollingTimerRef.current = setInterval(fetchStatus, 2400); + return () => { + if (pollingTimerRef.current) { + clearInterval(pollingTimerRef.current); + pollingTimerRef.current = null; + } + }; + }, [taskId]); + + // 启动浮动和闪烁动画 - 更快的动画速度 + useEffect(() => { + // 上下浮动动画 - 加快速度 + const floatAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(floatAnim, { + toValue: -10, + duration: 1000, + useNativeDriver: true, + }), + Animated.timing(floatAnim, { + toValue: 0, + duration: 1000, + useNativeDriver: true, + }), + ]) + ); + + // 透明度闪烁动画 - 加快速度,增加对比度 + const opacityAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(opacityAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + }), + Animated.timing(opacityAnim, { + toValue: 0.4, + duration: 800, + useNativeDriver: true, + }), + ]) + ); + + floatAnimation.start(); + opacityAnimation.start(); + + return () => { + floatAnimation.stop(); + opacityAnimation.stop(); + }; + }, []); + + const progress = task?.progress ?? Math.min(100, (currentStepIndex / STATUS_STEPS.length) * 100 + 10); + + return ( + + + router.back()} transparent /> + + + + + {cover ? ( + + ) : ( + + )} + + {/* 识别中的点阵网格动画效果 - 带深色蒙版 */} + {task?.status !== 'completed' && task?.status !== 'failed' && ( + <> + {/* 深色半透明蒙版层,让点阵更清晰 */} + + + {/* 渐变蒙版边框,增加视觉层次 */} + + + {/* 点阵网格动画 */} + + {Array.from({ length: 11 }).map((_, idx) => ( + + {Array.from({ length: 11 }).map((__, jdx) => ( + + ))} + + ))} + + + )} + + + + + {Math.round(progress)}% + + + + {STATUS_STEPS.map((step, index) => { + const active = index === currentStepIndex; + const done = index < currentStepIndex; + return ( + + + + {step.label} + + + ); + })} + {task?.status === 'completed' && ( + + + 识别完成,正在载入详情... + + )} + + + + {loading ? : null} + {error ? {error} : null} + + + {/* 识别提示弹窗 */} + + + e.stopPropagation()} + style={styles.errorModalContainer} + > + + + {/* 标题 */} + 需要重新拍摄 + + {/* 提示信息 */} + + {errorMessage} + + + {/* 重新拍摄按钮 */} + + {isLiquidGlassAvailable() ? ( + + + + 重新拍摄 + + + ) : ( + + + + 重新拍摄 + + + )} + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + heroCard: { + marginHorizontal: 20, + marginTop: 24, + borderRadius: 24, + backgroundColor: '#fff', + padding: 16, + shadowColor: '#0f172a', + shadowOpacity: 0.08, + shadowRadius: 18, + shadowOffset: { width: 0, height: 10 }, + }, + heroImageWrapper: { + height: 230, + borderRadius: 18, + overflow: 'hidden', + backgroundColor: '#e2e8f0', + }, + heroImage: { + width: '100%', + height: '100%', + }, + heroPlaceholder: { + flex: 1, + backgroundColor: '#e2e8f0', + }, + // 深色蒙版层,让点阵更清晰可见 + overlayMask: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + backgroundColor: 'rgba(15, 23, 42, 0.35)', + }, + // 渐变边框效果 + gradientBorder: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: 18, + }, + // 点阵网格容器 + dottedGrid: { + position: 'absolute', + left: 16, + right: 16, + top: 16, + bottom: 16, + justifyContent: 'space-between', + }, + dotRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + // 单个点样式 - 更明亮和更大的发光效果 + dot: { + width: 5, + height: 5, + borderRadius: 2.5, + backgroundColor: '#FFFFFF', + shadowColor: '#0ea5e9', + shadowOpacity: 0.9, + shadowRadius: 6, + shadowOffset: { width: 0, height: 0 }, + }, + progressRow: { + height: 8, + backgroundColor: '#f1f5f9', + borderRadius: 10, + marginTop: 14, + overflow: 'hidden', + }, + progressBar: { + height: '100%', + borderRadius: 10, + backgroundColor: '#0ea5e9', + }, + progressText: { + marginTop: 8, + fontSize: 14, + fontWeight: '700', + color: '#0f172a', + textAlign: 'right', + }, + stepList: { + marginTop: 24, + marginHorizontal: 24, + gap: 14, + }, + stepRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + bullet: { + width: 14, + height: 14, + borderRadius: 7, + backgroundColor: '#e2e8f0', + }, + bulletActive: { + backgroundColor: '#0ea5e9', + }, + bulletDone: { + backgroundColor: '#22c55e', + }, + stepLabel: { + fontSize: 15, + color: '#94a3b8', + }, + stepLabelActive: { + color: '#0f172a', + fontWeight: '700', + }, + stepLabelDone: { + color: '#16a34a', + fontWeight: '700', + }, + loadingBox: { + marginTop: 30, + alignItems: 'center', + gap: 12, + }, + errorText: { + color: '#ef4444', + fontSize: 14, + }, + // Modal 样式 + modalOverlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(15, 23, 42, 0.4)', + }, + errorModalContainer: { + width: SCREEN_WIDTH - 48, + backgroundColor: '#FFFFFF', + borderRadius: 28, + overflow: 'hidden', + shadowColor: '#0ea5e9', + shadowOpacity: 0.15, + shadowRadius: 24, + shadowOffset: { width: 0, height: 8 }, + elevation: 8, + }, + errorModalContent: { + padding: 32, + alignItems: 'center', + }, + errorIconContainer: { + marginBottom: 24, + }, + errorIconCircle: { + width: 96, + height: 96, + borderRadius: 48, + backgroundColor: 'rgba(14, 165, 233, 0.08)', + alignItems: 'center', + justifyContent: 'center', + }, + errorModalTitle: { + fontSize: 22, + fontWeight: '700', + color: '#0f172a', + marginBottom: 16, + textAlign: 'center', + }, + errorMessageBox: { + backgroundColor: '#f0f9ff', + borderRadius: 16, + padding: 20, + marginBottom: 28, + width: '100%', + borderWidth: 1, + borderColor: 'rgba(14, 165, 233, 0.2)', + }, + errorMessageText: { + fontSize: 15, + lineHeight: 24, + color: '#475569', + textAlign: 'center', + }, + retryButton: { + borderRadius: 16, + overflow: 'hidden', + shadowColor: '#0ea5e9', + shadowOpacity: 0.25, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 6, + }, + retryButtonGradient: { + paddingVertical: 16, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + retryButtonText: { + fontSize: 18, + fontWeight: '700', + color: '#FFFFFF', + }, +}); diff --git a/app/medications/edit-frequency.tsx b/app/medications/edit-frequency.tsx index 14aca0e..e58a763 100644 --- a/app/medications/edit-frequency.tsx +++ b/app/medications/edit-frequency.tsx @@ -4,7 +4,6 @@ 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'; @@ -211,13 +210,6 @@ export default function EditMedicationFrequencyScreen() { }) ).unwrap(); - // 重新安排药品通知 - try { - await medicationNotificationService.scheduleMedicationNotifications(updated); - } catch (error) { - console.error('[MEDICATION] 安排药品通知失败:', error); - } - router.back(); } catch (err) { console.error('更新频率失败', err); diff --git a/components/medication/MedicationAddOptionsSheet.tsx b/components/medication/MedicationAddOptionsSheet.tsx new file mode 100644 index 0000000..3552220 --- /dev/null +++ b/components/medication/MedicationAddOptionsSheet.tsx @@ -0,0 +1,409 @@ +import { Ionicons } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import React, { useEffect, useRef, useState } from 'react'; +import { Animated, Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +type Props = { + visible: boolean; + onClose: () => void; + onManualAdd: () => void; + onAiRecognize: () => void; +}; + +export function MedicationAddOptionsSheet({ visible, onClose, onManualAdd, onAiRecognize }: Props) { + const translateY = useRef(new Animated.Value(300)).current; + const opacity = useRef(new Animated.Value(0)).current; + const [modalVisible, setModalVisible] = useState(false); + + useEffect(() => { + if (visible) { + // 打开时:先显示 Modal,然后执行动画 + setModalVisible(true); + Animated.parallel([ + Animated.spring(translateY, { + toValue: 0, + tension: 65, + friction: 11, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + ]).start(); + } else if (modalVisible) { + // 关闭时:先执行动画,动画完成后隐藏 Modal + Animated.parallel([ + Animated.timing(translateY, { + toValue: 300, + duration: 200, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + ]).start(({ finished }) => { + if (finished) { + setModalVisible(false); + } + }); + } + }, [visible, modalVisible, opacity, translateY]); + + const handleClose = () => { + // 触发关闭动画 + onClose(); + }; + + return ( + + + + + + {/* Header */} + + + 添加药物 + 选择录入方式 + + + + + + + {/* AI 智能识别 - 主推荐 */} + + + {/* 推荐标签 */} + + + 推荐使用 + + + + + + + + + AI 智能识别 + + 拍照识别药品信息{'\n'}自动生成提醒计划 + + + + + 快速识别 + + + + 智能填充 + + + + + + + + {/* AI 说明 */} + + + 需会员或 AI 次数 · 拍摄时确保光线充足 + + + + + {/* 分隔线 */} + + + + + + + {/* 手动录入 - 次要选项 */} + + + + + + + + 手动录入 + + 逐项填写药品信息和服用计划 + + + + + + 免费 + + + + + + + {/* 底部安全距离 */} + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'transparent', + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.4)', + }, + sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + backgroundColor: '#fff', + borderTopLeftRadius: 32, + borderTopRightRadius: 32, + paddingTop: 24, + paddingHorizontal: 20, + shadowColor: '#000', + shadowOpacity: 0.15, + shadowRadius: 20, + shadowOffset: { width: 0, height: -8 }, + elevation: 12, + }, + + // Header + header: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + marginBottom: 24, + }, + headerLeft: { + flex: 1, + }, + title: { + fontSize: 24, + fontWeight: '700', + color: '#0f172a', + marginBottom: 4, + }, + subtitle: { + fontSize: 14, + color: '#64748b', + fontWeight: '500', + }, + closeButton: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#f1f5f9', + alignItems: 'center', + justifyContent: 'center', + marginLeft: 12, + }, + + // AI 卡片 - 主推荐 + aiCard: { + borderRadius: 24, + padding: 20, + marginBottom: 20, + overflow: 'hidden', + shadowColor: '#0ea5e9', + shadowOpacity: 0.3, + shadowRadius: 16, + shadowOffset: { width: 0, height: 8 }, + elevation: 8, + }, + recommendBadge: { + position: 'absolute', + top: 16, + right: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 4, + backgroundColor: 'rgba(255,255,255,0.25)', + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 16, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.3)', + }, + recommendText: { + fontSize: 12, + fontWeight: '700', + color: '#fff', + }, + aiContent: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + aiLeft: { + flex: 1, + flexDirection: 'row', + gap: 16, + }, + aiIconWrapper: { + width: 56, + height: 56, + borderRadius: 16, + backgroundColor: 'rgba(255,255,255,0.2)', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: 'rgba(255,255,255,0.3)', + }, + aiTexts: { + flex: 1, + gap: 8, + }, + aiTitle: { + fontSize: 20, + fontWeight: '700', + color: '#fff', + }, + aiDescription: { + fontSize: 14, + color: 'rgba(255,255,255,0.9)', + lineHeight: 20, + }, + aiFeatures: { + flexDirection: 'row', + gap: 12, + marginTop: 4, + }, + featureItem: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + featureText: { + fontSize: 12, + fontWeight: '600', + color: '#fff', + }, + aiImage: { + width: 80, + height: 80, + marginLeft: 12, + }, + aiFooter: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: 'rgba(255,255,255,0.2)', + }, + aiFooterText: { + fontSize: 12, + color: 'rgba(255,255,255,0.8)', + fontWeight: '500', + }, + + // 分隔线 + divider: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 20, + }, + dividerLine: { + flex: 1, + height: 1, + backgroundColor: '#e2e8f0', + }, + dividerText: { + fontSize: 13, + color: '#94a3b8', + fontWeight: '600', + marginHorizontal: 16, + }, + + // 手动录入卡片 - 次要选项 + manualCard: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: '#f8fafc', + borderRadius: 20, + padding: 16, + borderWidth: 1.5, + borderColor: '#e2e8f0', + }, + manualLeft: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + manualIconWrapper: { + width: 48, + height: 48, + borderRadius: 14, + backgroundColor: '#eef2ff', + alignItems: 'center', + justifyContent: 'center', + }, + manualTexts: { + flex: 1, + gap: 4, + }, + manualTitle: { + fontSize: 16, + fontWeight: '700', + color: '#0f172a', + }, + manualDescription: { + fontSize: 13, + color: '#64748b', + lineHeight: 18, + }, + manualRight: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginLeft: 12, + }, + manualBadge: { + backgroundColor: '#dcfce7', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + }, + manualBadgeText: { + fontSize: 11, + fontWeight: '700', + color: '#16a34a', + }, + + // 底部安全距离 + safeArea: { + height: 32, + }, +}); diff --git a/components/medications/ExpiryDatePickerModal.tsx b/components/medications/ExpiryDatePickerModal.tsx new file mode 100644 index 0000000..cf787f6 --- /dev/null +++ b/components/medications/ExpiryDatePickerModal.tsx @@ -0,0 +1,205 @@ +import { ThemedText } from '@/components/ThemedText'; +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { useI18n } from '@/hooks/useI18n'; +import DateTimePicker from '@react-native-community/datetimepicker'; +import dayjs from 'dayjs'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Alert, Modal, Platform, Pressable, StyleSheet, View } from 'react-native'; + +interface ExpiryDatePickerModalProps { + visible: boolean; + currentDate: Date | null; + onClose: () => void; + onConfirm: (date: Date) => void; + isAiDraft?: boolean; +} + +/** + * 有效期日期选择器组件 + * + * 功能: + * - 显示日期选择器弹窗 + * - 验证日期不能早于今天 + * - iOS 显示内联日历,Android 显示原生对话框 + * - 支持取消和确认操作 + */ +export function ExpiryDatePickerModal({ + visible, + currentDate, + onClose, + onConfirm, + isAiDraft = false, +}: ExpiryDatePickerModalProps) { + const { t } = useI18n(); + const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors; + const colors = Colors[scheme]; + + // 内部状态:选择的日期值 + const [selectedDate, setSelectedDate] = useState(currentDate || new Date()); + + // 当弹窗显示时,同步当前日期 + useEffect(() => { + if (visible) { + setSelectedDate(currentDate || new Date()); + } + }, [visible, currentDate]); + + /** + * 处理日期变化 + * iOS: 实时更新选择的日期 + * Android: 在用户点击确定时直接确认 + */ + const handleDateChange = useCallback( + (event: any, date?: Date) => { + if (Platform.OS === 'ios') { + // iOS: 实时更新内部状态 + if (date) { + setSelectedDate(date); + } + } else { + // Android: 处理用户操作 + if (event.type === 'set' && date) { + // 用户点击确定 + validateAndConfirm(date); + } else { + // 用户点击取消 + onClose(); + } + } + }, + [onClose] + ); + + /** + * 验证并确认日期 + */ + const validateAndConfirm = useCallback( + (dateToConfirm: Date) => { + // 验证有效期不能早于今天 + const today = new Date(); + today.setHours(0, 0, 0, 0); + const selected = new Date(dateToConfirm); + selected.setHours(0, 0, 0, 0); + + if (selected < today) { + Alert.alert('日期无效', '有效期不能早于今天'); + return; + } + + // 检查日期是否真的发生了变化 + const currentExpiry = currentDate ? dayjs(currentDate).format('YYYY-MM-DD') : null; + const newExpiry = dayjs(dateToConfirm).format('YYYY-MM-DD'); + + if (currentExpiry === newExpiry) { + // 日期没有变化,直接关闭 + onClose(); + return; + } + + // 日期有效且发生了变化,执行确认回调 + onConfirm(dateToConfirm); + onClose(); + }, + [currentDate, onClose, onConfirm] + ); + + /** + * iOS 平台的确认按钮处理 + */ + const handleIOSConfirm = useCallback(() => { + validateAndConfirm(selectedDate); + }, [selectedDate, validateAndConfirm]); + + return ( + + + + + 选择有效期 + + + + + {/* iOS 平台显示确认和取消按钮 */} + {Platform.OS === 'ios' && ( + + + + {t('medications.detail.pickers.cancel')} + + + + + {t('medications.detail.pickers.confirm')} + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(15, 23, 42, 0.4)', + }, + sheet: { + 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, + }, + title: { + fontSize: 20, + fontWeight: '700', + marginBottom: 20, + textAlign: 'center', + }, + actions: { + flexDirection: 'row', + gap: 12, + marginTop: 16, + }, + btn: { + flex: 1, + paddingVertical: 14, + borderRadius: 16, + alignItems: 'center', + borderWidth: 1, + }, + btnPrimary: { + borderWidth: 0, + }, + btnText: { + fontSize: 16, + fontWeight: '600', + }, +}); \ No newline at end of file diff --git a/components/medications/MedicationPhotoGuideModal.tsx b/components/medications/MedicationPhotoGuideModal.tsx new file mode 100644 index 0000000..6c13bd9 --- /dev/null +++ b/components/medications/MedicationPhotoGuideModal.tsx @@ -0,0 +1,265 @@ +import { Ionicons } from '@expo/vector-icons'; +import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import React from 'react'; +import { + Dimensions, + Modal, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); + +interface MedicationPhotoGuideModalProps { + visible: boolean; + onClose: () => void; +} + +/** + * 药品拍摄指南弹窗组件 + * 展示如何正确拍摄药品照片的说明和示例 + */ +export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoGuideModalProps) { + return ( + + + e.stopPropagation()} + style={styles.guideModalContainer} + > + + {/* 标题部分 */} + + 规范 + 拍摄图片清晰 + + + {/* 示例图片 */} + + {/* 正确示例 */} + + + + + + + + + + + {/* 错误示例 */} + + + + + + + + + + + + {/* 说明文字 */} + + + 请拍摄药品正面\背面的产品名称\说明部分。 + + + 注意拍摄时光线充分,没有反光,文字部分清晰可见。照片的清晰度会影响识别的准确率。 + + + + {/* 确认按钮 */} + + {isLiquidGlassAvailable() ? ( + + + 知道了! + + + ) : ( + + + 知道了! + + + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + modalOverlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + guideModalContainer: { + width: SCREEN_WIDTH - 48, + maxHeight: '80%', + backgroundColor: '#FFFFFF', + borderRadius: 24, + overflow: 'hidden', + shadowColor: '#000', + shadowOpacity: 0.25, + shadowRadius: 20, + shadowOffset: { width: 0, height: 10 }, + elevation: 10, + }, + guideModalContent: { + padding: 24, + }, + guideHeader: { + alignItems: 'center', + marginBottom: 24, + }, + guideStepBadge: { + fontSize: 16, + fontWeight: '700', + color: '#FFB300', + marginBottom: 8, + }, + guideTitle: { + fontSize: 22, + fontWeight: '700', + color: '#0f172a', + textAlign: 'center', + }, + guideImagesContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 24, + gap: 12, + }, + guideImageWrapper: { + flex: 1, + alignItems: 'center', + }, + guideImageBox: { + width: '100%', + aspectRatio: 1, + borderRadius: 16, + overflow: 'hidden', + backgroundColor: '#f8fafc', + position: 'relative', + borderWidth: 2, + borderColor: '#4CAF50', + }, + guideImageBoxBlur: { + borderColor: '#F44336', + }, + guideImage: { + width: '100%', + height: '100%', + }, + guideImageIcon: { + position: 'absolute', + top: 8, + left: 8, + zIndex: 1, + }, + guideImageIndicator: { + marginTop: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(76, 175, 80, 0.1)', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 12, + }, + guideImageIndicatorError: { + backgroundColor: 'rgba(244, 67, 54, 0.1)', + }, + guideDescription: { + backgroundColor: '#f8fafc', + borderRadius: 16, + padding: 16, + marginBottom: 24, + }, + guideDescriptionText: { + fontSize: 14, + lineHeight: 22, + color: '#475569', + marginBottom: 8, + }, + guideConfirmButton: { + borderRadius: 16, + overflow: 'hidden', + shadowColor: '#FFB300', + shadowOpacity: 0.3, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 6, + }, + guideConfirmButtonGradient: { + paddingVertical: 16, + alignItems: 'center', + justifyContent: 'center', + }, + guideConfirmButtonText: { + fontSize: 18, + fontWeight: '700', + color: '#FFFFFF', + }, +}); \ No newline at end of file diff --git a/i18n/index.ts b/i18n/index.ts index cfbc577..b9be013 100644 --- a/i18n/index.ts +++ b/i18n/index.ts @@ -652,6 +652,7 @@ const medicationsResources = { formLabels: { capsule: '胶囊', pill: '药片', + tablet: '药片', injection: '注射', spray: '喷雾', drop: '滴剂', @@ -690,6 +691,7 @@ const medicationsResources = { period: '服药周期', time: '用药时间', frequency: '频率', + expiryDate: '药品有效期', longTerm: '长期', periodMessage: '开始服药日期:{{startDate}}\n{{endDateInfo}}', longTermPlan: '服药计划:长期服药', @@ -1423,6 +1425,7 @@ const resources = { formLabels: { capsule: 'Capsule', pill: 'Tablet', + tablet: 'Tablet', injection: 'Injection', spray: 'Spray', drop: 'Drops', @@ -1461,6 +1464,7 @@ const resources = { period: 'Medication Period', time: 'Medication Time', frequency: 'Frequency', + expiryDate: 'Expiry Date', longTerm: 'Long-term', periodMessage: 'Start date: {{startDate}}\n{{endDateInfo}}', longTermPlan: 'Medication plan: Long-term medication', diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index 589df1b..7bdaffd 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -27,7 +27,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.28 + 1.0.29 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/OutLive/SplashScreen.storyboard b/ios/OutLive/SplashScreen.storyboard index 654bf67..e828a6b 100644 --- a/ios/OutLive/SplashScreen.storyboard +++ b/ios/OutLive/SplashScreen.storyboard @@ -17,8 +17,8 @@ - - + + @@ -31,7 +31,7 @@ - + diff --git a/services/medicationNotificationCleanup.ts b/services/medicationNotificationCleanup.ts new file mode 100644 index 0000000..fdc0fce --- /dev/null +++ b/services/medicationNotificationCleanup.ts @@ -0,0 +1,66 @@ +import { getItemSync, setItemSync } from '@/utils/kvStore'; +import * as Notifications from 'expo-notifications'; + +const CLEANUP_KEY = 'medication_notifications_cleaned_v1'; + +/** + * 清理所有旧的药品本地通知 + * 这个函数会在应用启动时执行一次,用于清理从本地通知迁移到服务端推送之前注册的所有药品通知 + */ +export async function cleanupLegacyMedicationNotifications(): Promise { + try { + // 检查是否已经执行过清理 + const alreadyCleaned = getItemSync(CLEANUP_KEY); + if (alreadyCleaned === 'true') { + console.log('[药品通知清理] 已执行过清理,跳过'); + return; + } + + console.log('[药品通知清理] 开始清理旧的药品本地通知...'); + + // 获取所有已安排的通知 + const scheduledNotifications = await Notifications.getAllScheduledNotificationsAsync(); + + if (scheduledNotifications.length === 0) { + console.log('[药品通知清理] 没有待清理的通知'); + setItemSync(CLEANUP_KEY, 'true'); + return; + } + + console.log(`[药品通知清理] 发现 ${scheduledNotifications.length} 个已安排的通知,开始筛选药品通知...`); + + // 筛选出药品相关的通知并取消 + let cleanedCount = 0; + for (const notification of scheduledNotifications) { + const data = notification.content.data; + + // 识别药品通知的特征: + // 1. data.type === 'medication_reminder' + // 2. data.medicationId 存在 + // 3. identifier 包含 'medication' 关键字 + const isMedicationNotification = + data?.type === 'medication_reminder' || + data?.medicationId || + notification.identifier?.includes('medication'); + + if (isMedicationNotification) { + try { + await Notifications.cancelScheduledNotificationAsync(notification.identifier); + cleanedCount++; + console.log(`[药品通知清理] 已取消通知: ${notification.identifier}`); + } catch (error) { + console.error(`[药品通知清理] 取消通知失败: ${notification.identifier}`, error); + } + } + } + + console.log(`[药品通知清理] ✅ 清理完成,共取消 ${cleanedCount} 个药品通知`); + + // 标记清理已完成 + setItemSync(CLEANUP_KEY, 'true'); + } catch (error) { + console.error('[药品通知清理] ❌ 清理过程出错:', error); + // 即使出错也标记为已清理,避免每次启动都尝试 + setItemSync(CLEANUP_KEY, 'true'); + } +} \ No newline at end of file diff --git a/services/medicationNotifications.ts b/services/medicationNotifications.ts deleted file mode 100644 index 47d349e..0000000 --- a/services/medicationNotifications.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { Medication } from '@/types/medication'; -import { getMedicationReminderEnabled, getNotificationEnabled } from '@/utils/userPreferences'; -import * as Notifications from 'expo-notifications'; -import { notificationService, NotificationTypes } from './notifications'; - -/** - * 药品通知服务 - * 负责管理药品提醒通知的调度和取消 - */ -export class MedicationNotificationService { - private static instance: MedicationNotificationService; - private notificationPrefix = 'medication_'; - - private constructor() {} - - public static getInstance(): MedicationNotificationService { - if (!MedicationNotificationService.instance) { - MedicationNotificationService.instance = new MedicationNotificationService(); - } - return MedicationNotificationService.instance; - } - - /** - * 检查是否可以发送药品通知 - */ - private async canSendMedicationNotifications(): Promise { - try { - // 检查总通知开关 - const notificationEnabled = await getNotificationEnabled(); - if (!notificationEnabled) { - console.log('总通知开关已关闭,跳过药品通知'); - return false; - } - - // 检查药品通知开关 - const medicationReminderEnabled = await getMedicationReminderEnabled(); - if (!medicationReminderEnabled) { - console.log('药品通知开关已关闭,跳过药品通知'); - return false; - } - - // 检查系统权限 - const permissionStatus = await notificationService.getPermissionStatus(); - if (permissionStatus !== 'granted') { - console.log('系统通知权限未授予,跳过药品通知'); - return false; - } - - return true; - } catch (error) { - console.error('检查药品通知权限失败:', error); - return false; - } - } - - /** - * 为药品安排通知 - */ - async scheduleMedicationNotifications(medication: Medication): Promise { - try { - const canSend = await this.canSendMedicationNotifications(); - if (!canSend) { - console.log('药品通知权限不足,跳过安排通知'); - return; - } - - // 先取消该药品的现有通知 - await this.cancelMedicationNotifications(medication.id); - - // 为每个用药时间安排通知 - for (const time of medication.medicationTimes) { - const [hour, minute] = time.split(':').map(Number); - - // 创建通知内容 - const notificationContent = { - title: '用药提醒', - body: `该服用 ${medication.name} 了 (${medication.dosageValue}${medication.dosageUnit})`, - data: { - type: NotificationTypes.MEDICATION_REMINDER, - medicationId: medication.id, - medicationName: medication.name, - dosage: `${medication.dosageValue}${medication.dosageUnit}`, - }, - sound: true, - priority: 'high' as const, - }; - - // 安排每日重复通知 - const notificationId = await notificationService.scheduleCalendarRepeatingNotification( - notificationContent, - { - type: Notifications.SchedulableTriggerInputTypes.DAILY, - hour, - minute, - } - ); - - console.log(`已为药品 ${medication.name} 安排通知,时间: ${time},通知ID: ${notificationId}`); - } - } catch (error) { - console.error('安排药品通知失败:', error); - } - } - - /** - * 取消药品的所有通知 - */ - async cancelMedicationNotifications(medicationId: string): Promise { - try { - // 获取所有已安排的通知 - const allNotifications = await notificationService.getAllScheduledNotifications(); - - // 过滤出该药品的通知并取消 - for (const notification of allNotifications) { - const data = notification.content.data as any; - if (data?.type === NotificationTypes.MEDICATION_REMINDER && - data?.medicationId === medicationId) { - await notificationService.cancelNotification(notification.identifier); - console.log(`已取消药品通知,ID: ${notification.identifier}`); - } - } - } catch (error) { - console.error('取消药品通知失败:', error); - } - } - - /** - * 重新安排所有激活药品的通知 - */ - async rescheduleAllMedicationNotifications(medications: Medication[]): Promise { - try { - // 先取消所有药品通知 - for (const medication of medications) { - await this.cancelMedicationNotifications(medication.id); - } - - // 重新安排激活药品的通知 - const activeMedications = medications.filter(m => m.isActive); - for (const medication of activeMedications) { - await this.scheduleMedicationNotifications(medication); - } - - console.log(`已重新安排 ${activeMedications.length} 个激活药品的通知`); - } catch (error) { - console.error('重新安排药品通知失败:', error); - } - } - - /** - * 发送立即的药品通知(用于测试) - */ - async sendTestMedicationNotification(medication: Medication): Promise { - try { - const canSend = await this.canSendMedicationNotifications(); - if (!canSend) { - throw new Error('药品通知权限不足'); - } - - return await notificationService.sendImmediateNotification({ - title: '用药提醒测试', - body: `这是 ${medication.name} 的测试通知 (${medication.dosageValue}${medication.dosageUnit})`, - data: { - type: NotificationTypes.MEDICATION_REMINDER, - medicationId: medication.id, - medicationName: medication.name, - dosage: `${medication.dosageValue}${medication.dosageUnit}`, - }, - sound: true, - priority: 'high', - }); - } catch (error) { - console.error('发送测试药品通知失败:', error); - throw error; - } - } - - /** - * 获取所有已安排的药品通知 - */ - async getMedicationNotifications(): Promise { - try { - const allNotifications = await notificationService.getAllScheduledNotifications(); - - // 过滤出药品相关的通知 - return allNotifications.filter(notification => - notification.content.data?.type === NotificationTypes.MEDICATION_REMINDER - ); - } catch (error) { - console.error('获取药品通知失败:', error); - return []; - } - } -} - -// 导出单例实例 -export const medicationNotificationService = MedicationNotificationService.getInstance(); diff --git a/services/medications.ts b/services/medications.ts index 1807204..a48681f 100644 --- a/services/medications.ts +++ b/services/medications.ts @@ -7,6 +7,7 @@ import type { Medication, MedicationAiAnalysisV2, MedicationForm, + MedicationRecognitionTask, MedicationRecord, MedicationStatus, RepeatPattern, @@ -28,6 +29,7 @@ export interface CreateMedicationDto { medicationTimes: string[]; startDate: string; endDate?: string | null; + expiryDate?: string | null; repeatPattern?: RepeatPattern; note?: string; } @@ -344,3 +346,39 @@ export async function analyzeMedicationV2( {} ); } + +// ==================== AI 药品识别任务 ==================== + +export interface CreateMedicationRecognitionDto { + frontImageUrl: string; + sideImageUrl: string; + auxiliaryImageUrl?: string; +} + +export interface ConfirmMedicationRecognitionDto { + name?: string; + timesPerDay?: number; + medicationTimes?: string[]; + startDate?: string; + endDate?: string | null; + note?: string; +} + +export const createMedicationRecognitionTask = async ( + dto: CreateMedicationRecognitionDto +): Promise<{ taskId: string; status: MedicationRecognitionTask['status'] }> => { + return api.post('/medications/ai-recognize', dto); +}; + +export const getMedicationRecognitionStatus = async ( + taskId: string +): Promise => { + return api.get(`/medications/ai-recognize/${taskId}/status`); +}; + +export const confirmMedicationRecognition = async ( + taskId: string, + payload?: ConfirmMedicationRecognitionDto +): Promise => { + return api.post(`/medications/ai-recognize/${taskId}/confirm`, payload ?? {}); +}; diff --git a/services/notifications.ts b/services/notifications.ts index 84061fb..9e6281a 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -238,11 +238,6 @@ export class NotificationService { console.log('用户点击了 HRV 压力通知', data); const targetUrl = (data?.url as string) || '/(tabs)/statistics'; router.push(targetUrl as any); - } else if (data?.type === NotificationTypes.MEDICATION_REMINDER) { - // 处理药品提醒通知 - console.log('用户点击了药品提醒通知', data); - // 跳转到药品页面 - router.push('/(tabs)/medications' as any); } } @@ -584,7 +579,6 @@ export const NotificationTypes = { WORKOUT_COMPLETION: 'workout_completion', FASTING_START: 'fasting_start', FASTING_END: 'fasting_end', - MEDICATION_REMINDER: 'medication_reminder', HRV_STRESS_ALERT: 'hrv_stress_alert', } as const; @@ -623,21 +617,3 @@ export const sendMoodCheckinReminder = (title: string, body: string, date?: Date } }; -export const sendMedicationReminder = (title: string, body: string, medicationId?: string, date?: Date) => { - const notification: NotificationData = { - title, - body, - data: { - type: NotificationTypes.MEDICATION_REMINDER, - medicationId: medicationId || '' - }, - sound: true, - priority: 'high', - }; - - if (date) { - return notificationService.scheduleNotificationAtDate(notification, date); - } else { - return notificationService.sendImmediateNotification(notification); - } -}; diff --git a/types/medication.ts b/types/medication.ts index 983ebf9..1a6bf9a 100644 --- a/types/medication.ts +++ b/types/medication.ts @@ -40,6 +40,7 @@ export interface Medication { medicationTimes: string[]; // 服药时间列表 ['08:00', '20:00'] startDate: string; // 开始日期 ISO endDate?: string | null; // 结束日期 ISO(可选) + expiryDate?: string | null; // 药品有效期 ISO(可选) repeatPattern: RepeatPattern; // 重复模式 note?: string; // 备注 aiAnalysis?: string; // AI 分析结果(Markdown 格式) @@ -105,3 +106,48 @@ export interface MedicationAiAnalysisV2 { storageAdvice: string[]; // 储存建议 healthAdvice: string[]; // 健康建议/使用建议 } + +/** + * AI 识别结果结构化数据 + */ +export interface MedicationAiRecognitionResult { + name: string; + photoUrl?: string; + form?: MedicationForm; + dosageValue?: number; + dosageUnit?: string; + timesPerDay?: number; + medicationTimes?: string[]; + startDate?: string; + endDate?: string | null; + expiryDate?: string | null; + note?: string; + suitableFor?: string[]; + unsuitableFor?: string[]; + mainIngredients?: string[]; + mainUsage?: string; + sideEffects?: string[]; + storageAdvice?: string[]; + healthAdvice?: string[]; + confidence?: number; +} + +export type MedicationRecognitionStatus = + | 'pending' + | 'analyzing_product' + | 'analyzing_suitability' + | 'analyzing_ingredients' + | 'analyzing_effects' + | 'completed' + | 'failed'; + +export interface MedicationRecognitionTask { + taskId: string; + status: MedicationRecognitionStatus; + currentStep?: string; + progress?: number; + result?: MedicationAiRecognitionResult; + errorMessage?: string; // 识别失败时的错误信息 + createdAt: string; + completedAt?: string; +}