diff --git a/app/(tabs)/medications.tsx b/app/(tabs)/medications.tsx index ce12cb4..d598f91 100644 --- a/app/(tabs)/medications.tsx +++ b/app/(tabs)/medications.tsx @@ -5,6 +5,7 @@ import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsS import { ThemedText } from '@/components/ThemedText'; import { IconSymbol } from '@/components/ui/IconSymbol'; import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet'; +import { MedicationAiSummaryInfoSheet } from '@/components/ui/MedicationAiSummaryInfoSheet'; import { Colors } from '@/constants/Colors'; import { useMembershipModal } from '@/contexts/MembershipModalContext'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; @@ -59,6 +60,7 @@ export default function MedicationsScreen() { const [isCelebrationVisible, setIsCelebrationVisible] = useState(false); const [disclaimerVisible, setDisclaimerVisible] = useState(false); const [pendingAction, setPendingAction] = useState<'manual' | null>(null); + const [aiSummaryInfoVisible, setAiSummaryInfoVisible] = useState(false); // 从 Redux 获取数据 const selectedKey = selectedDate.format('YYYY-MM-DD'); @@ -115,6 +117,33 @@ export default function MedicationsScreen() { setPendingAction(null); }, []); + const handleOpenAiSummary = useCallback(async () => { + // 先检查登录状态 + const isLoggedIn = await ensureLoggedIn(); + if (!isLoggedIn) return; + + // 检查 VIP 权限 + const access = checkServiceAccess(); + if (!access.canUseService) { + // 非会员显示介绍弹窗 + setAiSummaryInfoVisible(true); + return; + } + + // 会员直接跳转到 AI 总结页面 + router.push('/medications/ai-summary'); + }, [checkServiceAccess, ensureLoggedIn]); + + const handleAiSummaryInfoConfirm = useCallback(() => { + setAiSummaryInfoVisible(false); + // 点击"我要订阅"后,弹出会员订阅弹窗 + openMembershipModal(); + }, [openMembershipModal]); + + const handleAiSummaryInfoClose = useCallback(() => { + setAiSummaryInfoVisible(false); + }, []); + const handleOpenMedicationManagement = useCallback(() => { router.push('/medications/manage-medications'); }, []); @@ -285,31 +314,59 @@ export default function MedicationsScreen() { - - {isLiquidGlassAvailable() ? ( + {isLiquidGlassAvailable() ? ( + - + - ) : ( - - - - )} - + + ) : ( + + + + )} - - {isLiquidGlassAvailable() ? ( + {isLiquidGlassAvailable() ? ( + + + + + + ) : ( + + + + )} + + {isLiquidGlassAvailable() ? ( + - ) : ( - - - - )} - + + ) : ( + + + + )} @@ -430,6 +491,13 @@ export default function MedicationsScreen() { onClose={handleDisclaimerClose} onConfirm={handleDisclaimerConfirm} /> + + {/* AI 用药总结介绍弹窗 */} + ); } diff --git a/app/medications/ai-summary.tsx b/app/medications/ai-summary.tsx new file mode 100644 index 0000000..09aa7b4 --- /dev/null +++ b/app/medications/ai-summary.tsx @@ -0,0 +1,886 @@ +import { ThemedText } from '@/components/ThemedText'; +import { HeaderBar } from '@/components/ui/HeaderBar'; +import { IconSymbol } from '@/components/ui/IconSymbol'; +import { getMedicationAiSummary } from '@/services/medications'; +import { type MedicationAiSummary, type MedicationAiSummaryItem } from '@/types/medication'; +import { useFocusEffect } from '@react-navigation/native'; +import dayjs from 'dayjs'; +import { LinearGradient } from 'expo-linear-gradient'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ActivityIndicator, + Modal, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +export default function MedicationAiSummaryScreen() { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(''); + const [showInfoModal, setShowInfoModal] = useState(false); + const [showCompletionInfoModal, setShowCompletionInfoModal] = useState(false); + + const fetchSummary = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await getMedicationAiSummary(); + setSummary(data); + setLastUpdated(dayjs().format('YYYY.MM.DD HH:mm')); + } catch (err: any) { + const status = err?.status; + if (status === 403) { + setError(t('medications.aiSummary.error403')); + } else { + setError(err?.message || t('medications.aiSummary.genericError')); + } + setSummary(null); + } finally { + setLoading(false); + } + }, [t]); + + useFocusEffect( + useCallback(() => { + fetchSummary(); + }, [fetchSummary]) + ); + + const handleExplainRefresh = useCallback(() => { + setShowInfoModal(true); + }, []); + + const handleExplainCompletion = useCallback(() => { + setShowCompletionInfoModal(true); + }, []); + + const medicationItems = summary?.medicationAnalysis ?? []; + const isEmpty = !loading && !error && medicationItems.length === 0; + + const stats = useMemo(() => { + const plannedDoses = medicationItems.reduce((acc, item) => acc + (item.plannedDoses || 0), 0); + const takenDoses = medicationItems.reduce((acc, item) => acc + (item.takenDoses || 0), 0); + const completion = plannedDoses > 0 ? takenDoses / plannedDoses : 0; + const avgCompletion = + medicationItems.length > 0 + ? medicationItems.reduce((acc, item) => acc + (item.completionRate || 0), 0) / + medicationItems.length + : 0; + const plannedDays = medicationItems.reduce((acc, item) => acc + (item.plannedDays || 0), 0); + + return { + plannedDoses, + takenDoses, + completion, + avgCompletion, + plannedDays, + activePlans: medicationItems.length, + }; + }, [medicationItems]); + + const completionPercent = Math.min(100, Math.round(stats.completion * 100)); + + const renderMedicationCard = (item: MedicationAiSummaryItem) => { + const percent = Math.min(100, Math.round((item.completionRate || 0) * 100)); + return ( + + + + {item.name} + + {t('medications.aiSummary.daysLabel', { + days: item.plannedDays, + times: item.timesPerDay, + })} + + + + + + {t('medications.aiSummary.badges.adherence')} + + + + + + + + + + {t('medications.aiSummary.completionLabel', { value: percent })} + + + + + + {t('medications.aiSummary.doseSummary', { + taken: item.takenDoses, + planned: item.plannedDoses, + })} + + + {dayjs(item.startDate).format('YYYY.MM.DD')} + + + + ); + }; + + const headerTitle = ( + + {t('medications.aiSummary.title')} + {t('medications.aiSummary.subtitle')} + + ); + + return ( + + + + + + + + + } + /> + + + + + + {t('medications.aiSummary.overviewTitle')} + + + {lastUpdated ? t('medications.aiSummary.updatedAt', { time: lastUpdated }) : ' '} + + + + + + {completionPercent}% + + {t('medications.aiSummary.doseSummary', { + taken: stats.takenDoses, + planned: stats.plannedDoses, + })} + + + + + + + + {t('medications.aiSummary.badges.safety')} + + {stats.activePlans} + + {t('medications.aiSummary.stats.activePlans')} + + + + + + + + {t('medications.aiSummary.stats.avgCompletion')} + + + {Math.round(stats.avgCompletion * 100)}% + + + + + {t('medications.aiSummary.stats.activeDays')} + + {stats.plannedDays} + + + + {t('medications.aiSummary.stats.takenDoses')} + + {stats.takenDoses} + + + + + {error ? ( + + {error} + + {t('medications.aiSummary.retry')} + + + ) : ( + <> + + + + {t('medications.aiSummary.keyInsights')} + + + + + {t('medications.aiSummary.pillChip')} + + + + + {summary?.keyInsights || t('medications.aiSummary.keyInsightPlaceholder')} + + + + + + + {t('medications.aiSummary.listTitle')} + + + + + + {loading ? ( + + + + {t('medications.aiSummary.refresh')} + + + ) : isEmpty ? ( + + + {t('medications.aiSummary.emptyTitle')} + + + {t('medications.aiSummary.emptyDescription')} + + + ) : ( + {medicationItems.map(renderMedicationCard)} + )} + + + )} + + + setShowInfoModal(false)} + > + setShowInfoModal(false)} + > + e.stopPropagation()} + style={styles.infoModal} + > + + + {t('medications.aiSummary.infoModal.badge')} + {t('medications.aiSummary.infoModal.title')} + setShowInfoModal(false)} + style={styles.infoClose} + accessibilityLabel="close" + > + + + + + + {t('medications.aiSummary.infoModal.point1')} + + + {t('medications.aiSummary.infoModal.point2')} + + + {t('medications.aiSummary.infoModal.point3')} + + + {t('medications.aiSummary.infoModal.point4')} + + + + setShowInfoModal(false)} + > + + {t('medications.aiSummary.infoModal.button')} + + + + + + + + + setShowCompletionInfoModal(false)} + > + setShowCompletionInfoModal(false)} + > + e.stopPropagation()} + style={styles.infoModal} + > + + + {t('medications.aiSummary.completionInfoModal.badge')} + {t('medications.aiSummary.completionInfoModal.title')} + setShowCompletionInfoModal(false)} + style={styles.infoClose} + accessibilityLabel="close" + > + + + + + + {t('medications.aiSummary.completionInfoModal.point1')} + + + {t('medications.aiSummary.completionInfoModal.point2')} + + + {t('medications.aiSummary.completionInfoModal.point3')} + + + {t('medications.aiSummary.completionInfoModal.point4')} + + + {t('medications.aiSummary.completionInfoModal.point5')} + + + + setShowCompletionInfoModal(false)} + > + + {t('medications.aiSummary.completionInfoModal.button')} + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0b0f16', + }, + scrollContent: { + paddingHorizontal: 20, + gap: 20, + }, + glowTop: { + position: 'absolute', + top: -80, + left: -40, + width: 200, + height: 200, + backgroundColor: '#1b2a44', + opacity: 0.35, + borderRadius: 140, + }, + glowBottom: { + position: 'absolute', + bottom: -120, + right: -60, + width: 240, + height: 240, + backgroundColor: '#123125', + opacity: 0.25, + borderRadius: 200, + }, + iconButton: { + width: 40, + height: 40, + borderRadius: 14, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.04)', + }, + headerTitle: { + alignItems: 'center', + flex: 1, + gap: 6, + }, + badge: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 999, + backgroundColor: '#d6b37f', + }, + badgeText: { + color: '#0b0f16', + fontSize: 12, + fontWeight: '700', + fontFamily: 'AliBold', + }, + title: { + color: '#f6f7fb', + fontSize: 22, + fontFamily: 'AliBold', + }, + subtitle: { + color: '#b9c2d3', + fontSize: 14, + fontFamily: 'AliRegular', + }, + heroCard: { + borderRadius: 24, + padding: 18, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.06)', + shadowColor: '#000', + shadowOpacity: 0.25, + shadowRadius: 16, + gap: 14, + }, + heroHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + heroLabel: { + color: '#f5f6fb', + fontSize: 16, + fontFamily: 'AliBold', + }, + updatedAt: { + color: '#8b94a8', + fontSize: 12, + fontFamily: 'AliRegular', + }, + heroMainRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: 16, + }, + heroLeft: { + flex: 1, + minWidth: 0, + }, + heroValue: { + color: '#36d0a5', + fontSize: 38, + lineHeight: 42, + fontFamily: 'AliBold', + letterSpacing: 0.5, + flexShrink: 1, + }, + heroCaption: { + color: '#c2ccdf', + fontSize: 13, + fontFamily: 'AliRegular', + marginTop: 4, + }, + heroProgressTrack: { + marginTop: 12, + height: 10, + borderRadius: 10, + backgroundColor: 'rgba(255,255,255,0.08)', + overflow: 'hidden', + }, + heroProgressFill: { + height: '100%', + borderRadius: 10, + backgroundColor: '#36d0a5', + }, + heroChip: { + paddingHorizontal: 14, + paddingVertical: 12, + borderRadius: 18, + backgroundColor: 'rgba(214, 179, 127, 0.12)', + borderWidth: 1, + borderColor: 'rgba(214, 179, 127, 0.3)', + minWidth: 120, + alignItems: 'flex-start', + gap: 4, + }, + heroChipLabel: { + color: '#d6b37f', + fontSize: 12, + fontFamily: 'AliRegular', + }, + heroChipValue: { + color: '#f6f7fb', + fontSize: 20, + fontFamily: 'AliBold', + lineHeight: 24, + }, + heroChipHint: { + color: '#b9c2d3', + fontSize: 12, + fontFamily: 'AliRegular', + }, + heroStatsRow: { + flexDirection: 'row', + gap: 12, + justifyContent: 'space-between', + }, + heroStatItem: { + flex: 1, + padding: 12, + borderRadius: 14, + backgroundColor: 'rgba(255,255,255,0.04)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.04)', + }, + heroStatLabel: { + color: '#9dabc4', + fontSize: 12, + fontFamily: 'AliRegular', + }, + heroStatValue: { + color: '#f6f7fb', + fontSize: 18, + marginTop: 6, + fontFamily: 'AliBold', + }, + sectionCard: { + borderRadius: 20, + padding: 16, + backgroundColor: 'rgba(255,255,255,0.03)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.05)', + gap: 12, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + sectionTitle: { + color: '#f5f6fb', + fontSize: 16, + fontFamily: 'AliBold', + }, + pillChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: '#d6b37f', + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 999, + }, + pillChipText: { + color: '#0b0f16', + fontSize: 12, + fontFamily: 'AliBold', + }, + insightText: { + color: '#d9e2f2', + fontSize: 15, + lineHeight: 22, + fontFamily: 'AliRegular', + }, + planList: { + gap: 12, + }, + planCard: { + borderRadius: 16, + padding: 14, + backgroundColor: 'rgba(255,255,255,0.04)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.06)', + gap: 10, + }, + planHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + planName: { + color: '#f6f7fb', + fontSize: 16, + fontFamily: 'AliBold', + }, + planMeta: { + color: '#9dabc4', + fontSize: 12, + fontFamily: 'AliRegular', + marginTop: 2, + }, + planChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 999, + backgroundColor: 'rgba(214, 179, 127, 0.15)', + borderWidth: 1, + borderColor: 'rgba(214, 179, 127, 0.35)', + }, + planChipText: { + color: '#d6b37f', + fontSize: 12, + fontFamily: 'AliBold', + }, + progressRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + progressTrack: { + flex: 1, + height: 10, + borderRadius: 10, + backgroundColor: 'rgba(255,255,255,0.08)', + overflow: 'hidden', + }, + progressFill: { + height: '100%', + backgroundColor: '#36d0a5', + borderRadius: 10, + }, + progressValue: { + color: '#f6f7fb', + fontSize: 12, + fontFamily: 'AliBold', + }, + planFooter: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + planStat: { + color: '#c7d1e4', + fontSize: 13, + fontFamily: 'AliRegular', + }, + planDate: { + color: '#7f8aa4', + fontSize: 12, + fontFamily: 'AliRegular', + }, + errorCard: { + padding: 16, + borderRadius: 16, + backgroundColor: 'rgba(255, 86, 86, 0.08)', + borderWidth: 1, + borderColor: 'rgba(255, 86, 86, 0.3)', + alignItems: 'center', + gap: 12, + }, + errorTitle: { + color: '#ff9c9c', + fontSize: 14, + textAlign: 'center', + fontFamily: 'AliBold', + }, + retryButton: { + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 999, + backgroundColor: '#ff9c9c', + }, + retryText: { + color: '#0b0f16', + fontSize: 13, + fontFamily: 'AliBold', + }, + loadingRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingVertical: 12, + }, + loadingText: { + color: '#c7d1e4', + fontSize: 13, + fontFamily: 'AliRegular', + }, + emptyState: { + paddingVertical: 12, + gap: 6, + }, + emptyTitle: { + color: '#f6f7fb', + fontSize: 15, + fontFamily: 'AliBold', + }, + emptySubtitle: { + color: '#9dabc4', + fontSize: 13, + fontFamily: 'AliRegular', + lineHeight: 20, + }, + infoOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.6)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 20, + }, + infoModal: { + width: '100%', + maxWidth: 400, + borderRadius: 24, + overflow: 'hidden', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + }, + infoGradient: { + padding: 24, + gap: 20, + }, + infoHeader: { + alignItems: 'center', + justifyContent: 'center', + marginBottom: 4, + }, + infoBadge: { + color: '#d6b37f', + fontSize: 24, + lineHeight: 28, + fontFamily: 'AliBold', + marginBottom: 10, + letterSpacing: 0.5, + }, + infoTitle: { + color: '#f6f7fb', + fontSize: 16, + fontFamily: 'AliBold', + textAlign: 'center', + }, + infoClose: { + position: 'absolute', + right: -4, + top: -4, + padding: 8, + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 18, + backgroundColor: 'rgba(255,255,255,0.05)', + }, + infoContent: { + gap: 14, + }, + infoText: { + color: '#d9e2f2', + fontSize: 14, + lineHeight: 18, + fontFamily: 'AliRegular', + }, + infoButtonContainer: { + marginTop: 12, + alignItems: 'center', + }, + infoButtonWrapper: { + // minWidth: 120, + // maxWidth: 180, + }, + infoButton: { + borderRadius: 12, + paddingVertical: 10, + paddingHorizontal: 28, + alignItems: 'center', + overflow: 'hidden', + }, + infoButtonGlass: { + paddingVertical: 10, + paddingHorizontal: 28, + alignItems: 'center', + }, + infoButtonText: { + color: '#0b0f16', + fontSize: 15, + fontFamily: 'AliBold', + letterSpacing: 0.2, + }, + infoIconButton: { + width: 28, + height: 28, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(139, 148, 168, 0.1)', + }, +}); diff --git a/assets/images/medicine/medicine-ai-summary.png b/assets/images/medicine/medicine-ai-summary.png new file mode 100644 index 0000000..c3851ef Binary files /dev/null and b/assets/images/medicine/medicine-ai-summary.png differ diff --git a/components/StepsCard.tsx b/components/StepsCard.tsx index 5edc090..ca187d8 100644 --- a/components/StepsCard.tsx +++ b/components/StepsCard.tsx @@ -1,14 +1,17 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Animated, - InteractionManager, - StyleSheet, - Text, - TouchableOpacity, - View, - ViewStyle + Animated, + InteractionManager, + StyleSheet, + Text, + TouchableOpacity, + View, + ViewStyle } from 'react-native'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { ChallengeType } from '@/services/challengesApi'; +import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice'; import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health'; import { logger } from '@/utils/logger'; import dayjs from 'dayjs'; @@ -20,8 +23,8 @@ import { AnimatedNumber } from './AnimatedNumber'; // import Svg, { Rect } from 'react-native-svg'; interface StepsCardProps { - curDate: Date - stepGoal: number; + curDate: Date; + stepGoal?: number; style?: ViewStyle; } @@ -31,9 +34,20 @@ const StepsCard: React.FC = ({ }) => { const { t } = useTranslation(); const router = useRouter(); + const dispatch = useAppDispatch(); + const challenges = useAppSelector(selectChallengeList); - const [stepCount, setStepCount] = useState(0) - const [hourlySteps, setHourSteps] = useState([]) + const [stepCount, setStepCount] = useState(0); + const [hourlySteps, setHourSteps] = useState([]); + + // 过滤出已参加的步数挑战 + const joinedStepsChallenges = useMemo( + () => challenges.filter((challenge) => challenge.type === ChallengeType.STEP && challenge.isJoined && challenge.status === 'ongoing'), + [challenges] + ); + + // 跟踪上次上报的记录,避免重复上报 + const lastReportedRef = useRef<{ date: string; value: number } | null>(null); const getStepData = useCallback(async (date: Date) => { @@ -59,6 +73,42 @@ const StepsCard: React.FC = ({ } }, [curDate]); + // 步数挑战进度上报逻辑 + useEffect(() => { + if (!curDate || !stepCount || !joinedStepsChallenges.length) { + return; + } + + // 如果当前日期不是今天,不上报 + if (!dayjs(curDate).isSame(dayjs(), 'day')) { + return; + } + + const dateKey = dayjs(curDate).format('YYYY-MM-DD'); + const lastReport = lastReportedRef.current; + + if (lastReport && lastReport.date === dateKey && lastReport.value === stepCount) { + return; + } + + const reportProgress = async () => { + const stepsChallenge = joinedStepsChallenges.find((c) => c.type === ChallengeType.STEP); + if (!stepsChallenge) { + return; + } + + try { + await dispatch(reportChallengeProgress({ id: stepsChallenge.id, value: stepCount })).unwrap(); + } catch (error) { + logger.warn('StepsCard: Challenge progress report failed', { error, challengeId: stepsChallenge.id }); + } + + lastReportedRef.current = { date: dateKey, value: stepCount }; + }; + + reportProgress(); + }, [dispatch, joinedStepsChallenges, curDate, stepCount]); + // 优化:减少动画值数量,只为有数据的小时创建动画 const animatedValues = useRef>(new Map()).current; diff --git a/components/StepsCardOptimized.tsx b/components/StepsCardOptimized.tsx deleted file mode 100644 index 6b6b180..0000000 --- a/components/StepsCardOptimized.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - Animated, - StyleSheet, - Text, - TouchableOpacity, - View, - ViewStyle, - InteractionManager -} from 'react-native'; - -import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health'; -import { logger } from '@/utils/logger'; -import dayjs from 'dayjs'; -import { Image } from 'expo-image'; -import { useRouter } from 'expo-router'; -import { AnimatedNumber } from './AnimatedNumber'; - -interface StepsCardProps { - curDate: Date - stepGoal: number; - style?: ViewStyle; -} - -const StepsCardOptimized: React.FC = ({ - curDate, - style, -}) => { - const router = useRouter(); - - const [stepCount, setStepCount] = useState(0) - const [hourlySteps, setHourSteps] = useState([]) - const [isLoading, setIsLoading] = useState(false) - - // 优化:使用debounce减少频繁的数据获取 - const debounceTimer = useRef(null); - - const getStepData = useCallback(async (date: Date) => { - try { - setIsLoading(true); - logger.info('获取步数数据...'); - - // 先获取步数,立即更新UI - const steps = await fetchStepCount(date); - setStepCount(steps); - - // 清除之前的定时器 - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); - } - - // 使用 InteractionManager 在空闲时获取更复杂的小时数据 - InteractionManager.runAfterInteractions(async () => { - try { - const hourly = await fetchHourlyStepSamples(date); - setHourSteps(hourly); - } catch (error) { - logger.error('获取小时步数数据失败:', error); - } finally { - setIsLoading(false); - } - }); - - } catch (error) { - logger.error('获取步数数据失败:', error); - setIsLoading(false); - } - }, []); - - useEffect(() => { - if (curDate) { - getStepData(curDate); - } - }, [curDate, getStepData]); - - // 优化:减少动画值数量,只为有数据的小时创建动画 - const animatedValues = useRef>(new Map()).current; - - // 优化:简化柱状图数据计算,减少计算量 - const chartData = useMemo(() => { - if (!hourlySteps || hourlySteps.length === 0) { - return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 })); - } - - // 优化:只计算有数据的小时的最大步数 - const activeSteps = hourlySteps.filter(data => data.steps > 0); - if (activeSteps.length === 0) { - return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 })); - } - - const maxSteps = Math.max(...activeSteps.map(data => data.steps)); - const maxHeight = 20; - - return hourlySteps.map(data => ({ - ...data, - height: data.steps > 0 ? (data.steps / maxSteps) * maxHeight : 0 - })); - }, [hourlySteps]); - - // 获取当前小时 - const currentHour = new Date().getHours(); - - // 优化:延迟执行动画,减少UI阻塞 - useEffect(() => { - const hasData = chartData && chartData.length > 0 && chartData.some(data => data.steps > 0); - - if (hasData && !isLoading) { - // 使用 InteractionManager 确保动画不会阻塞用户交互 - InteractionManager.runAfterInteractions(() => { - // 只为有数据的小时创建和执行动画 - const animations = chartData - .map((data, index) => { - if (data.steps > 0) { - // 懒创建动画值 - if (!animatedValues.has(index)) { - animatedValues.set(index, new Animated.Value(0)); - } - - const animValue = animatedValues.get(index)!; - animValue.setValue(0); - - // 使用更高性能的timing动画替代spring - return Animated.timing(animValue, { - toValue: 1, - duration: 200, // 减少动画时长 - useNativeDriver: false, - }); - } - return null; - }) - .filter(Boolean) as Animated.CompositeAnimation[]; - - // 批量执行动画,提高性能 - if (animations.length > 0) { - Animated.stagger(50, animations).start(); - } - }); - } - }, [chartData, animatedValues, isLoading]); - - // 优化:使用React.memo包装复杂的渲染组件 - const ChartBars = useMemo(() => { - return chartData.map((data, index) => { - // 判断是否是当前小时或者有活动的小时 - const isActive = data.steps > 0; - const isCurrent = index <= currentHour; - - // 优化:只为有数据的柱体创建动画插值 - const animValue = animatedValues.get(index); - let animatedScale: Animated.AnimatedInterpolation | undefined; - let animatedOpacity: Animated.AnimatedInterpolation | undefined; - - if (animValue && isActive) { - animatedScale = animValue.interpolate({ - inputRange: [0, 1], - outputRange: [0, 1], - }); - - animatedOpacity = animValue.interpolate({ - inputRange: [0, 1], - outputRange: [0, 1], - }); - } - - return ( - - {/* 背景柱体 - 始终显示,使用相似色系的淡色 */} - - - {/* 数据柱体 - 只有当有数据时才显示并执行动画 */} - {isActive && ( - - )} - - ); - }); - }, [chartData, currentHour, animatedValues]); - - const CardContent = () => ( - <> - {/* 标题和步数显示 */} - - - 步数 - {isLoading && 加载中...} - - - {/* 柱状图 */} - - - - {ChartBars} - - - - - {/* 步数和目标显示 */} - - stepCount !== null ? `${Math.round(v)}` : '——'} - resetToken={stepCount} - /> - - - ); - - return ( - { - // 传递当前日期参数到详情页 - const dateParam = dayjs(curDate).format('YYYY-MM-DD'); - router.push(`/steps/detail?date=${dateParam}`); - }} - activeOpacity={0.8} - > - - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'space-between', - borderRadius: 20, - padding: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 4, - }, - shadowOpacity: 0.08, - shadowRadius: 20, - elevation: 8, - }, - header: { - flexDirection: 'row', - justifyContent: 'flex-start', - alignItems: 'center', - }, - titleIcon: { - width: 16, - height: 16, - marginRight: 6, - resizeMode: 'contain', - }, - title: { - fontSize: 14, - color: '#192126', - fontWeight: '600' - }, - loadingText: { - fontSize: 10, - color: '#666', - marginLeft: 8, - }, - chartContainer: { - flex: 1, - justifyContent: 'center', - marginTop: 6 - }, - chartWrapper: { - width: '100%', - alignItems: 'center', - }, - chartArea: { - flexDirection: 'row', - alignItems: 'flex-end', - height: 20, - width: '100%', - maxWidth: 240, - justifyContent: 'space-between', - paddingHorizontal: 4, - }, - barContainer: { - width: 4, - height: 20, - alignItems: 'center', - justifyContent: 'flex-end', - position: 'relative', - }, - chartBar: { - width: 4, - borderRadius: 1, - position: 'absolute', - bottom: 0, - }, - statsContainer: { - alignItems: 'flex-start', - marginTop: 6 - }, - stepCount: { - fontSize: 18, - fontWeight: '600', - color: '#192126', - }, -}); - -export default StepsCardOptimized; \ No newline at end of file diff --git a/components/ui/IconSymbol.tsx b/components/ui/IconSymbol.tsx index 73720f0..6f6eced 100644 --- a/components/ui/IconSymbol.tsx +++ b/components/ui/IconSymbol.tsx @@ -30,6 +30,9 @@ const MAPPING = { 'info.circle': 'info', 'magnifyingglass': 'search', 'xmark': 'close', + 'chevron.left': 'chevron-left', + 'sparkles': 'auto-awesome', + 'arrow.clockwise': 'refresh', } as IconMapping; /** diff --git a/components/ui/MedicationAiSummaryInfoSheet.tsx b/components/ui/MedicationAiSummaryInfoSheet.tsx new file mode 100644 index 0000000..93b12ef --- /dev/null +++ b/components/ui/MedicationAiSummaryInfoSheet.tsx @@ -0,0 +1,454 @@ +import { Ionicons } from '@expo/vector-icons'; +import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; +import * as Haptics from 'expo-haptics'; +import { Image } from 'expo-image'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + ActivityIndicator, + Animated, + BackHandler, + Dimensions, + Modal, + StyleSheet, + Text, + TouchableOpacity, + View +} from 'react-native'; +import ImageViewing from 'react-native-image-viewing'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useI18n } from '../../hooks/useI18n'; +import { triggerLightHaptic } from '../../utils/haptics'; + +const { height: screenHeight } = Dimensions.get('window'); + +interface MedicationAiSummaryInfoSheetProps { + visible: boolean; + onClose: () => void; + onConfirm: () => void; + loading?: boolean; +} + +/** + * AI 用药总结介绍弹窗组件 + * 用于展示 AI 用药总结功能的介绍和说明 + */ +export function MedicationAiSummaryInfoSheet({ + visible, + onClose, + onConfirm, + loading = false, +}: MedicationAiSummaryInfoSheetProps) { + const { t } = useI18n(); + const insets = useSafeAreaInsets(); + const translateY = useRef(new Animated.Value(screenHeight)).current; + const backdropOpacity = useRef(new Animated.Value(0)).current; + const [showImagePreview, setShowImagePreview] = useState(false); + + // 预览图片 - 直接使用 require 资源 + const imageSource = require('@/assets/images/medicine/medicine-ai-summary.png'); + + useEffect(() => { + 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(); + } else { + 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); + }); + } + }, [visible, backdropOpacity, translateY]); + + // 处理Android返回键关闭图片预览 + useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + if (showImagePreview) { + setShowImagePreview(false); + return true; // 阻止默认返回行为 + } + return false; + }); + + return () => backHandler.remove(); + }, [showImagePreview]); + + // 处理图片预览 + const handleImagePreview = useCallback(() => { + triggerLightHaptic(); + setShowImagePreview(true); + }, []); + + const handleClose = () => { + // 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿 + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch((error) => { + console.warn('[AI_SUMMARY] Haptic feedback failed:', error); + }); + onClose(); + }; + + const handleConfirm = () => { + if (loading) return; + // 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿 + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch((error) => { + console.warn('[AI_SUMMARY] Haptic feedback failed:', error); + }); + onConfirm(); + }; + + if (!visible) { + return null; + } + + return ( + + + + + + + + + + {/* 图标和标题 */} + + + + + {t('medications.aiSummaryInfo.title')} + + + {/* 介绍图片区域 */} + + + {/* 右上角查看大图提示按钮 */} + + {isLiquidGlassAvailable() ? ( + + + + ) : ( + + + + )} + + + + {/* 功能介绍内容 */} + + + + + + + {t('medications.aiSummaryInfo.features.intelligent.title')} + + {t('medications.aiSummaryInfo.features.intelligent.description')} + + + + + + + + + + {t('medications.aiSummaryInfo.features.tracking.title')} + + {t('medications.aiSummaryInfo.features.tracking.description')} + + + + + + + + + + {t('medications.aiSummaryInfo.features.professional.title')} + + {t('medications.aiSummaryInfo.features.professional.description')} + + + + + + {/* 确认按钮 - 支持 Liquid Glass */} + + + {isLiquidGlassAvailable() ? ( + + {loading ? ( + + ) : ( + <> + + {t('medications.aiSummaryInfo.confirmButton')} + + )} + + ) : ( + + {loading ? ( + + ) : ( + <> + + {t('medications.aiSummaryInfo.confirmButton')} + + )} + + )} + + + + + + {/* 图片预览 */} + setShowImagePreview(false)} + swipeToCloseEnabled={true} + doubleTapToZoomEnabled={true} + FooterComponent={() => ( + + setShowImagePreview(false)} + > + {t('medications.detail.imageViewer.close')} + + + )} + /> + + ); +} + +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: 20, + }, + handle: { + width: 50, + height: 4, + borderRadius: 2, + backgroundColor: '#E5E7EB', + alignSelf: 'center', + marginBottom: 8, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + iconContainer: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#F3E8FF', + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: 20, + fontWeight: '700', + color: '#111827', + }, + imageContainer: { + width: '100%', + height: 380, + borderRadius: 16, + overflow: 'hidden', + backgroundColor: '#F9FAFB', + }, + introImage: { + width: '100%', + height: '100%', + borderRadius: 16, + }, + contentContainer: { + gap: 16, + paddingVertical: 8, + }, + featureItem: { + flexDirection: 'row', + gap: 12, + alignItems: 'flex-start', + }, + featureIcon: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#F3E8FF', + alignItems: 'center', + justifyContent: 'center', + }, + featureContent: { + flex: 1, + }, + featureTitle: { + fontSize: 16, + fontWeight: '600', + color: '#111827', + marginBottom: 4, + }, + featureDescription: { + fontSize: 14, + lineHeight: 20, + color: '#6B7280', + }, + actions: { + marginTop: 8, + }, + confirmButton: { + height: 56, + borderRadius: 18, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + overflow: 'hidden', // 保证玻璃边界圆角效果 + }, + fallbackButton: { + backgroundColor: '#8B5CF6', + shadowColor: 'rgba(139, 92, 246, 0.45)', + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 1, + shadowRadius: 20, + elevation: 6, + }, + confirmText: { + fontSize: 16, + fontWeight: '700', + color: '#fff', + }, + // 图片预览相关样式 + viewImageButton: { + position: 'absolute', + top: 12, + right: 12, + zIndex: 1, + }, + glassViewButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + fallbackViewButton: { + borderWidth: 1, + borderColor: 'rgba(0, 0, 0, 0.1)', + backgroundColor: 'rgba(255, 255, 255, 0.8)', + }, + imageViewerFooter: { + position: 'absolute', + bottom: 60, + left: 20, + right: 20, + alignItems: 'center', + zIndex: 1, + }, + imageViewerFooterButton: { + backgroundColor: 'rgba(0, 0, 0, 0.7)', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 20, + }, + imageViewerFooterButtonText: { + color: '#FFF', + fontSize: 16, + fontWeight: '500', + }, +}); \ No newline at end of file diff --git a/i18n/en/medication.ts b/i18n/en/medication.ts index 57d507d..af7f7b2 100644 --- a/i18n/en/medication.ts +++ b/i18n/en/medication.ts @@ -469,4 +469,75 @@ export const medications = { button: 'Got it!', }, }, -}; \ No newline at end of file + aiSummary: { + title: 'AI Medication Summary', + headerBadge: 'AI Insight', + subtitle: 'Adherence and safety overview', + overviewTitle: 'Adherence snapshot', + keyInsights: 'AI key insight', + refresh: 'DeepSeek is analyzing, please wait...', + stats: { + activePlans: 'Active plans', + plannedDoses: 'Planned doses', + takenDoses: 'Taken doses', + completion: 'Overall completion', + avgCompletion: 'Avg adherence', + activeDays: 'Planned days', + }, + badges: { + adherence: 'Adherence', + safety: 'Monitoring', + }, + doseSummary: 'Completed {{taken}} / {{planned}}', + daysLabel: '{{days}} day plan • {{times}} times/day', + completionLabel: '{{value}}% completed', + emptyTitle: 'No active medication plans', + emptyDescription: 'Activate or add a plan to generate the AI summary.', + error403: 'Free AI quota is used up, please upgrade to continue.', + genericError: 'Unable to load AI summary, please try again later.', + keyInsightPlaceholder: 'No AI insight available yet.', + listTitle: 'Plan breakdown', + updatedAt: 'Updated {{time}}', + pillChip: 'Professional advice', + retry: 'Retry', + infoModal: { + badge: 'Info', + title: 'Refresh & Adherence', + point1: '• Daily Generation: Based on active medication plans and actual check-in data, generated daily.', + point2: '• Refresh Effect: Retrieves the latest plan vs. actual completion and AI analysis, no extra quota used.', + point3: '• Adherence: Degree of following the plan (completion rate). Higher means better compliance and lower risk.', + point4: '• Statistics: Only counts plans with isActive=true and not deleted; completion only counts records with status "taken".', + button: 'Got it', + }, + completionInfoModal: { + badge: 'Calculation', + title: 'Completion Calculation Logic', + point1: '• Overall completion = (Total actual doses taken ÷ Total planned doses) × 100%', + point2: '• Actual doses taken: Number of medication records marked as "taken"', + point3: '• Planned doses: Total doses calculated from the plan start date to current date based on daily frequency', + point4: '• Detailed calculation: (Current date - Start date + 1) × Daily doses, e.g.: Day 5 with 2 daily doses = 10 total planned doses', + point5: '• Individual plan completion = (Actual doses taken for that plan ÷ Planned doses for that plan) × 100%', + button: 'Understood', + }, + }, + aiSummaryInfo: { + title: 'AI Medication Summary', + placeholderImage: 'Intro Image', + viewImage: 'View Full Image', + features: { + intelligent: { + title: 'Intelligent Analysis', + description: 'AI deeply analyzes your medication records to provide personalized health recommendations', + }, + tracking: { + title: 'Trend Tracking', + description: 'Long-term tracking of medication effects to help optimize treatment plans', + }, + professional: { + title: 'Professional & Reliable', + description: 'Based on medical knowledge base, providing safe and reliable health analysis', + }, + }, + confirmButton: 'Subscribe Now', + }, +}; diff --git a/i18n/zh/medication.ts b/i18n/zh/medication.ts index c5d1af6..aec5fa7 100644 --- a/i18n/zh/medication.ts +++ b/i18n/zh/medication.ts @@ -469,4 +469,75 @@ export const medications = { button: '知道了!', }, }, -}; \ No newline at end of file + aiSummary: { + title: 'AI 用药总结', + headerBadge: 'AI 专业总结', + subtitle: '依从性与安全重点', + overviewTitle: '用药总览', + keyInsights: 'AI 重点解读', + refresh: 'DeepSeek 正在进行分析,请稍等', + stats: { + activePlans: '进行中计划', + plannedDoses: '计划总次数', + takenDoses: '已完成次数', + completion: '总体完成度', + avgCompletion: '平均依从度', + activeDays: '计划天数', + }, + badges: { + adherence: '依从性', + safety: '监测建议', + }, + doseSummary: '已完成 {{taken}} / {{planned}} 次', + daysLabel: '{{days}} 天计划 · 每日 {{times}} 次', + completionLabel: '完成度 {{value}}%', + emptyTitle: '暂无开启的用药计划', + emptyDescription: '激活或新增计划后,将自动生成 AI 总结。', + error403: '免费使用次数已用完,请开通会员获取更多使用次数', + genericError: '获取AI总结失败,请稍后重试', + keyInsightPlaceholder: '暂未生成解读', + listTitle: '计划分解', + updatedAt: '更新于 {{time}}', + pillChip: '专业建议', + retry: '重试', + infoModal: { + badge: '说明', + title: '刷新规律 & 依从度', + point1: '• 每日生成:基于当天已开启的用药计划与实际打卡数据,每天出一版总结。', + point2: '• 刷新作用:重新获取最新的计划 vs 实际完成度和 AI 解读,不会扣额外次数。', + point3: '• 依从度:按计划执行的程度(完成率)。越高代表越遵医嘱、风险越低。', + point4: '• 统计口径:仅统计 isActive=true 且未删除的计划;完成次数只计状态为 taken 的记录。', + button: '知道了', + }, + completionInfoModal: { + badge: '计算说明', + title: '完成度计算逻辑', + point1: '• 总体完成度 = 所有计划的实际服药次数总和 ÷ 所有计划的理论服药次数总和 × 100%', + point2: '• 实际服药次数:标记为"已服用"的用药记录数量', + point3: '• 理论服药次数:从计划开始时间到当前时间,按照每日服药频率计算的总次数', + point4: '• 理论次数详细计算:(当前日期 - 开始日期 + 1) × 每日服药次数,例如:今天是第5天,每日2次,则理论次数为10次', + point5: '• 单个计划完成度 = 该计划的已服药次数 ÷ 该计划的理论服药次数 × 100%', + button: '了解了', + }, + }, + aiSummaryInfo: { + title: 'AI 用药总结', + placeholderImage: '介绍图片', + viewImage: '查看大图', + features: { + intelligent: { + title: '智能分析', + description: 'AI 深度分析您的用药记录,提供个性化健康建议', + }, + tracking: { + title: '趋势追踪', + description: '长期追踪用药效果,帮助优化治疗方案', + }, + professional: { + title: '专业可靠', + description: '基于医学知识库,提供安全可靠的健康分析', + }, + }, + confirmButton: '我要订阅', + }, +}; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 696c84e..45bc04f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2684,128 +2684,128 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - EXApplication: a9d1c46d473d36f61302a9a81db2379441f3f094 - EXConstants: e6e50cdfcb4524f40121d1fdcff24e97b7dcd2fd - EXImageLoader: e501c001bc40b8326605e82e6e80363c80fe06b5 - EXNotifications: 7aab54f0e5f3023122bc95699eaff7c52bacb559 - Expo: e852e4b236f95ef9fee36ea9ab20bc6f59c76a10 - ExpoAppleAuthentication: 414e4316f8e25a2afbc3943cf725579c910f24b8 - ExpoAsset: ee515c16290e521de1870dcdee66d78173fbc533 - ExpoBackgroundTask: e048da30cd2d669c5ba20d5d704bee8dd6da320c - ExpoBlur: b5b7a26572b3c33a11f0b2aa2f95c17c4c393b76 - ExpoCamera: d1894aad960492f4881c1f83967167963365d937 - ExpoClipboard: 99109306a2d9ed2fbd16f6b856e6267b2afa8472 - ExpoFileSystem: 73a9f3f2e0affc61eba5b9326153f64870438af1 - ExpoFont: b881d43057dceb7b31ff767b24f612609e80f60f - ExpoGlassEffect: 3d147d753d3bfe1a5d6b7920560e206e3e98c19e - ExpoHaptics: b48d913e7e5f23816c6f130e525c9a6501b160b5 - ExpoHead: 16bab3395d4328e88e4282a4f6ef4f44b1225c8d - ExpoImage: 6eb842cd07817402640545c41884dd7f5fbfbca5 - ExpoImagePicker: bd0a5c81d7734548f6908a480609257e85d19ea8 - ExpoKeepAwake: 3f5e3ac53627849174f3603271df8e08f174ed4a - ExpoLinearGradient: f9e7182e5253d53b2de4134b69d70bbfc2d50588 - ExpoLinking: 50a65cd7beb6051ffc82f84ffb33961dd5e55a5b - ExpoLocalization: 6c6f0f89ad2822001ab0bc2eb6d4d980c77f080c - ExpoMediaLibrary: 648cee3f5dcba13410ec9cc8ac9a426e89a61a31 - ExpoModulesCore: e37f2bfc6f5b553989e1a67e15b7c5c8bbcac0cc - ExpoQuickActions: 62b9db8a20618be1cc19efa3b562ac963c803d58 - ExpoSplashScreen: f46795cd52cdad65d30e54043f04c86401c4f85d - ExpoSQLite: f9d1202877e12bfa78a58309a3977ee4ea0b1314 - ExpoSymbols: ef7b8ac77ac2d496b1bc3f0f7daf5e19c3a9933a - ExpoSystemUI: 9441d46a8efbf9224d1b2e6b18042452ffd0ed79 - ExpoUI: 821b058da921ea4aa6172b36d080991ea6fb2fae - ExpoWebBrowser: 51218ce6ef35ea769e33409aac87fea3df4b919d - EXTaskManager: 53f87ed11659341c3f3f02c0041498ef293f5684 + EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7 + EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3 + EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05 + EXNotifications: 7cff475adb5d7a255a9ea46bbd2589cb3b454506 + Expo: 111394d38f32be09385d4c7f70cc96d2da438d0d + ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e + ExpoAsset: d839c8eae8124470332408427327e8f88beb2dfd + ExpoBackgroundTask: e0d201d38539c571efc5f9cb661fae8ab36ed61b + ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f + ExpoCamera: 2a87c210f8955350ea5c70f1d539520b2fc5d940 + ExpoClipboard: af650d14765f19c60ce2a1eaf9dfe6445eff7365 + ExpoFileSystem: 77157a101e03150a4ea4f854b4dd44883c93ae0a + ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961 + ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac + ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84 + ExpoHead: 95a6ee0be1142320bccf07961d6a1502ded5d6ac + ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566 + ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c + ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe + ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27 + ExpoLinking: 77455aa013e9b6a3601de03ecfab09858ee1b031 + ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca + ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe + ExpoModulesCore: e8ec7f8727caf51a49d495598303dd420ca994bf + ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f + ExpoSplashScreen: 268b2f128dc04284c21010540a6c4dd9f95003e3 + ExpoSQLite: 7fa091ba5562474093fef09be644161a65e11b3f + ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68 + ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7 + ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804 + ExpoWebBrowser: d04a0d6247a0bea4519fbc2ea816610019ad83e0 + EXTaskManager: cbbb80cbccea6487ccca0631809fbba2ed3e5271 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 lottie-ios: a881093fab623c467d3bce374367755c272bdd59 - lottie-react-native: 97a11537edc72d0763edab0c83e8cc8a0b9d8484 + lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c PurchasesHybridCommon: a4837eebc889b973668af685d6c23b89a038461d RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043 RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c React: 914f8695f9bf38e6418228c2ffb70021e559f92f React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71 - React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90 - React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151 - React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac - React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c + React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5 + React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599 + React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382 + React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da React-debug: 6328c2228e268846161f10082e80dc69eac2e90a - React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c - React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d - React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983 - React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4 - React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86 - React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d - React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28 - React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24 - React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf - React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc - React-ImageManager: 396128004783fc510e629124dce682d38d1088e7 - React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795 - React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0 - React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669 - React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51 - React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0 - React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb - React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270 - React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca - React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a - React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd - React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108 - React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c - react-native-render-html: 984dfe2294163d04bf5fe25d7c9f122e60e05ebe - react-native-safe-area-context: add9b4ba236fe95ec600604d0fc72f395433dd59 - react-native-view-shot: 26174e54ec6b4b7c5d70b86964b747919759adc1 - react-native-voice: f5e8eec2278451d0017eb6a30a6ccc726aca34e0 - react-native-webview: a4f0775a31b73cf13cfc3d2d2b119aa94ec76e49 - React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd + React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833 + React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66 + React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2 + React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a + React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8 + React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171 + React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f + React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6 + React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b + React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76 + React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e + React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d + React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de + React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3 + React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f + React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89 + React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f + React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e + React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a + React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf + React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28 + React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e + React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b + react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd + react-native-safe-area-context: 42a1b4f8774b577d03b53de7326e3d5757fe9513 + react-native-view-shot: fb3c0774edb448f42705491802a455beac1502a2 + react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c + react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a + React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c - React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f - React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490 + React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4 + React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526 React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916 - React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af - React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28 - React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264 - React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6 - React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266 - React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac - React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31 - React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000 - React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b - React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a - React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461 - React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e + React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e + React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016 + React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53 + React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea + React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797 + React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32 + React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b + React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20 + React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418 + React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035 + React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b + React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25 React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d - React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490 - React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812 - React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613 - React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4 - React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c - React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562 - React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7 - React-timing: 03c7217455d2bff459b27a3811be25796b600f47 - React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a - ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee - ReactCodegen: 4c44b74b77fc41ae25b9e2c7e9bd6e2bc772c23f - ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a + React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453 + React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89 + React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4 + React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0 + React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e + React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6 + React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f + React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801 + React-utils: 6e2035b53d087927768649a11a26c4e092448e34 + ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79 + ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc + ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8 ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a RevenueCat: 1e61140a343a77dc286f171b3ffab99ca09a4b57 - RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4 - RNCMaskedView: 3c9d7586e2b9bbab573591dcb823918bc4668005 - RNCPicker: e0149590451d5eae242cf686014a6f6d808f93c7 - RNDateTimePicker: 5e0a759109b63ebc661a4714712361d2d07142fe - RNDeviceInfo: 8b6fa8379062949dd79a009cf3d6b02a9c03ca59 - RNGestureHandler: 6a488ce85c88e82d8610db1108daf04e9b2d5162 - RNPurchases: 121c761620615ae624ba7a74e2e5b94254995e6c - RNReanimated: 00771d2ba7c810124852092eb29c0d1edf50cc58 - RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51 - RNSentry: 54f8041cd06d7ccf484171edde72f1b07323fb2e - RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528 - RNWorklets: 83609071441ac7d623f1e0e63b9043f4f345e2a2 + RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4 + RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035 + RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca + RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1 + RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c + RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3 + RNPurchases: 5f3cd4fea5ef2b3914c925b2201dd5cecd31922f + RNReanimated: 1442a577e066e662f0ce1cd1864a65c8e547aee0 + RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845 + RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e + RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34 + RNWorklets: 54d8dffb7f645873a58484658ddfd4bd1a9a0bc1 SDWebImage: d0184764be51240d49c761c37f53dd017e1ccaaf SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c diff --git a/services/challengesApi.ts b/services/challengesApi.ts index 67818ea..d18f149 100644 --- a/services/challengesApi.ts +++ b/services/challengesApi.ts @@ -39,6 +39,7 @@ export enum ChallengeType { MOOD = 'mood', SLEEP = 'sleep', WEIGHT = 'weight', + STEP = 'step', CUSTOM = 'custom', } diff --git a/services/medications.ts b/services/medications.ts index a48681f..7637e7e 100644 --- a/services/medications.ts +++ b/services/medications.ts @@ -6,6 +6,7 @@ import type { DailyMedicationStats, Medication, MedicationAiAnalysisV2, + MedicationAiSummary, MedicationForm, MedicationRecognitionTask, MedicationRecord, @@ -315,6 +316,14 @@ export const getOverallStats = async (): Promise<{ // ==================== AI 分析相关 ==================== +/** + * 获取 AI 用药总结 + * @returns 当前激活用药计划的 AI 关键解读与完成度 + */ +export const getMedicationAiSummary = async (): Promise => { + return api.get('/medications/ai-summary'); +}; + /** * 流式获取药品 AI 分析 * @param medicationId 药品 ID diff --git a/services/notifications.ts b/services/notifications.ts index da7b55f..1eaf4bc 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -189,9 +189,13 @@ export class NotificationService { } else if (data?.type === 'goal_achievement') { // 处理目标达成通知 console.log('用户点击了目标达成通知'); - } else if (data?.type === 'mood_checkin') { + } else if (data?.type === 'mood_checkin' || data?.type === 'mood_checkin_reminder') { // 处理心情打卡提醒 console.log('用户点击了心情打卡提醒'); + router.push({ + pathname: '/mood/edit', + params: { date: new Date().toISOString().split('T')[0] } + } as any); } else if (data?.type === 'goal_reminder') { // 处理目标提醒通知 console.log('用户点击了目标提醒通知', data); @@ -221,13 +225,21 @@ export class NotificationService { if (data?.url) { router.push(data.url as any); } - } else if (data?.type === 'water_reminder' || data?.type === 'regular_water_reminder') { + } else if (data?.type === 'water_reminder' || data?.type === 'regular_water_reminder' || data?.type === 'custom_water_reminder') { // 处理喝水提醒通知 console.log('用户点击了喝水提醒通知', data); // 跳转到统计页面查看喝水进度 if (data?.url) { router.push(data.url as any); } + } else if (data?.type === 'daily_summary' || data?.type === 'daily_summary_reminder') { + // 处理每日总结通知 + console.log('用户点击了每日总结通知', data); + if (data?.url) { + router.push(data.url as any); + } else { + router.push('/(tabs)/statistics' as any); + } } else if (data?.type === NotificationTypes.FASTING_START || data?.type === NotificationTypes.FASTING_END) { router.push(ROUTES.TAB_FASTING as any); } else if (data?.type === NotificationTypes.WORKOUT_COMPLETION) { diff --git a/types/medication.ts b/types/medication.ts index 774d89e..1c6ab08 100644 --- a/types/medication.ts +++ b/types/medication.ts @@ -95,6 +95,28 @@ export interface MedicationDisplayItem { medicationId: string; // 药物ID } +/** + * AI 用药总结 - 药物层级统计 + */ +export interface MedicationAiSummaryItem { + id: string; + name: string; + startDate: string; + plannedDays: number; + timesPerDay: number; + plannedDoses: number; + takenDoses: number; + completionRate: number; // 0-1 +} + +/** + * AI 用药总结响应 + */ +export interface MedicationAiSummary { + medicationAnalysis: MedicationAiSummaryItem[]; + keyInsights: string; +} + /** * 药品 AI 分析 V2 结构化数据 */ diff --git a/utils/notificationHelpers.ts b/utils/notificationHelpers.ts index df1e773..ec323c6 100644 --- a/utils/notificationHelpers.ts +++ b/utils/notificationHelpers.ts @@ -359,7 +359,7 @@ export class MoodNotificationHelpers { data: { type: 'mood_reminder', isDailyReminder: true, - url: '/mood-statistics' // 跳转到心情统计页面 + url: '/mood/edit' // 跳转到心情记录页面 }, sound: true, priority: 'normal', @@ -388,7 +388,7 @@ export class MoodNotificationHelpers { body: `${userName},夜深了~来记录一下今天的心情吧!每一份情感都值得被珍藏 ✨💕`, data: { type: 'mood_reminder', - url: '/mood-statistics' + url: '/mood/edit' }, sound: true, priority: 'normal',