From 3aafc50702670cafbac5099cdf8c3aca7df87f72 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 6 Nov 2025 17:51:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(medications):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=94=A8=E8=8D=AF=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增用药标签页,包含完整的用药记录界面 - 实现用药卡片组件,支持状态显示(已服用/未服用/已错过) - 增强日期选择器,添加"回到今天"快捷功能 - 添加用药相关的图标支持(pills.fill, plus) - 集成用药路由配置,支持标签页导航 该功能为用户提供完整的用药管理体验,包括用药记录、状态跟踪和日期筛选等核心功能。 --- app/(tabs)/_layout.tsx | 7 + app/(tabs)/medications.tsx | 423 +++++++++++++++++++++++ components/DateSelector.tsx | 74 +++- components/medication/MedicationCard.tsx | 301 ++++++++++++++++ components/ui/IconSymbol.tsx | 2 + constants/Routes.ts | 1 + 6 files changed, 798 insertions(+), 10 deletions(-) create mode 100644 app/(tabs)/medications.tsx create mode 100644 components/medication/MedicationCard.tsx diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index aff9d27..d7781c0 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -21,6 +21,7 @@ type TabConfig = { const TAB_CONFIGS: Record = { statistics: { icon: 'chart.pie.fill', title: '健康' }, + medications: { icon: 'pills.fill', title: '用药' }, fasting: { icon: 'timer', title: '断食' }, challenges: { icon: 'trophy.fill', title: '挑战' }, personal: { icon: 'person.fill', title: '个人' }, @@ -36,6 +37,7 @@ export default function TabLayout() { const isTabSelected = (routeName: string): boolean => { const routeMap: Record = { statistics: ROUTES.TAB_STATISTICS, + medications: ROUTES.TAB_MEDICATIONS, fasting: ROUTES.TAB_FASTING, challenges: ROUTES.TAB_CHALLENGES, personal: ROUTES.TAB_PERSONAL, @@ -176,6 +178,10 @@ export default function TabLayout() { + + + + @@ -198,6 +204,7 @@ export default function TabLayout() { > + diff --git a/app/(tabs)/medications.tsx b/app/(tabs)/medications.tsx new file mode 100644 index 0000000..174b440 --- /dev/null +++ b/app/(tabs)/medications.tsx @@ -0,0 +1,423 @@ +import { DateSelector } from '@/components/DateSelector'; +import { MedicationCard, type Medication, type MedicationStatus } from '@/components/medication/MedicationCard'; +import { ThemedText } from '@/components/ThemedText'; +import { IconSymbol } from '@/components/ui/IconSymbol'; +import { Colors } from '@/constants/Colors'; +import { useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { DEFAULT_MEMBER_NAME } from '@/store/userSlice'; +import dayjs, { Dayjs } from 'dayjs'; +import 'dayjs/locale/zh-cn'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import React, { useEffect, useMemo, useState } from 'react'; +import { ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +dayjs.locale('zh-cn'); + +type MedicationFilter = 'all' | 'taken' | 'missed'; + +type ThemeColors = (typeof Colors)[keyof typeof Colors]; + +const MEDICATION_IMAGES = { + bottle: require('@/assets/images/icons/icon-healthy-diet.png'), + drops: require('@/assets/images/icons/icon-remind.png'), + vitamins: require('@/assets/images/icons/icon-blood-oxygen.png'), +}; + +export default function MedicationsScreen() { + const insets = useSafeAreaInsets(); + const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; + const colors: ThemeColors = Colors[theme]; + const userProfile = useAppSelector((state) => state.user.profile); + + const [selectedDate, setSelectedDate] = useState(dayjs()); + const [selectedDateIndex, setSelectedDateIndex] = useState(selectedDate.date() - 1); + const [activeFilter, setActiveFilter] = useState('all'); + + const scheduledMedications = useMemo(() => { + const today = dayjs(); + const todayKey = today.format('YYYY-MM-DD'); + const yesterdayKey = today.subtract(1, 'day').format('YYYY-MM-DD'); + const twoDaysAgoKey = today.subtract(2, 'day').format('YYYY-MM-DD'); + + return { + [todayKey]: [ + { + id: 'med-1', + name: 'Metformin', + dosage: '1 粒胶囊', + scheduledTime: '09:00', + frequency: '每日', + status: 'upcoming' as MedicationStatus, + image: MEDICATION_IMAGES.bottle, + }, + { + id: 'med-2', + name: 'Captopril', + dosage: '2 粒胶囊', + scheduledTime: '20:00', + frequency: '每日', + status: 'upcoming' as MedicationStatus, + image: MEDICATION_IMAGES.vitamins, + }, + { + id: 'med-3', + name: 'B 12', + dosage: '1 次注射', + scheduledTime: '22:00', + frequency: '每日', + status: 'taken' as MedicationStatus, + image: MEDICATION_IMAGES.vitamins, + }, + { + id: 'med-4', + name: 'I-DROP MGD', + dosage: '2 滴', + scheduledTime: '22:00', + frequency: '每日', + status: 'missed' as MedicationStatus, + image: MEDICATION_IMAGES.drops, + }, + { + id: 'med-5', + name: 'Niacin', + dosage: '0.5 片', + scheduledTime: '22:00', + frequency: '每日', + status: 'missed' as MedicationStatus, + image: MEDICATION_IMAGES.bottle, + }, + ], + [yesterdayKey]: [ + { + id: 'med-6', + name: 'B 12', + dosage: '1 次注射', + scheduledTime: '22:00', + frequency: '每日', + status: 'taken' as MedicationStatus, + image: MEDICATION_IMAGES.vitamins, + }, + ], + [twoDaysAgoKey]: [ + { + id: 'med-7', + name: 'I-DROP MGD', + dosage: '2 滴', + scheduledTime: '22:00', + frequency: '每日', + status: 'missed' as MedicationStatus, + image: MEDICATION_IMAGES.drops, + }, + { + id: 'med-8', + name: 'Niacin', + dosage: '0.5 片', + scheduledTime: '22:00', + frequency: '每日', + status: 'missed' as MedicationStatus, + image: MEDICATION_IMAGES.bottle, + }, + ], + } as Record; + }, []); + + useEffect(() => { + setActiveFilter('all'); + }, [selectedDate]); + + const selectedKey = selectedDate.format('YYYY-MM-DD'); + const medicationsForDay = scheduledMedications[selectedKey] ?? []; + + const filteredMedications = useMemo(() => { + if (activeFilter === 'all') { + return medicationsForDay; + } + return medicationsForDay.filter((item) => item.status === activeFilter); + }, [activeFilter, medicationsForDay]); + + const counts = useMemo(() => { + const taken = medicationsForDay.filter((item) => item.status === 'taken').length; + const missed = medicationsForDay.filter((item) => item.status === 'missed').length; + return { + all: medicationsForDay.length, + taken, + missed, + }; + }, [medicationsForDay]); + + const displayName = userProfile.name?.trim() || DEFAULT_MEMBER_NAME; + const headerDateLabel = selectedDate.isSame(dayjs(), 'day') + ? `今天,${selectedDate.format('M月D日')}` + : selectedDate.format('M月D日 dddd'); + + const emptyState = filteredMedications.length === 0; + + return ( + + {/* 背景渐变 */} + + + {/* 装饰性圆圈 */} + + + + + + + 你好,{displayName} + + 欢迎来到用药助手! + + + + + + { + setSelectedDate(dayjs(date)); + setSelectedDateIndex(index); + }} + disableFutureDates + containerStyle={styles.dateSelectorContainer} + /> + + + + 今日用药 + + {(['all', 'taken', 'missed'] as MedicationFilter[]).map((filter) => { + const isActive = activeFilter === filter; + const labelMap: Record = { + all: '全部', + taken: '已服用', + missed: '未服用', + }; + return ( + setActiveFilter(filter)} + style={[ + styles.segment, + isActive && { backgroundColor: colors.primary }, + ]} + > + + {labelMap[filter]} + + + + {counts[filter]} + + + + ); + })} + + + + {emptyState ? ( + + + 今日暂无用药安排 + + 还未添加任何用药计划,快来补充吧。 + + + + + 添加用药 + + + + ) : ( + + {filteredMedications.map((item) => ( + + ))} + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + gradientBackground: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + decorativeCircle1: { + position: 'absolute', + top: 40, + right: 20, + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#0EA5E9', + opacity: 0.1, + }, + decorativeCircle2: { + position: 'absolute', + bottom: -15, + left: -15, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#0EA5E9', + opacity: 0.05, + }, + scrollContent: { + paddingHorizontal: 20, + gap: 24, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, + avatar: { + width: 60, + height: 60, + borderRadius: 30, + }, + greeting: { + fontSize: 24, + fontWeight: '600', + }, + welcome: { + marginTop: 6, + fontSize: 14, + }, + sectionSpacing: { + gap: 16, + }, + dateSelectorContainer: { + paddingRight: 0, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '500', + }, + sectionHeader: { + fontSize: 20, + fontWeight: '600', + }, + segmentedControl: { + flexDirection: 'row', + borderRadius: 18, + padding: 6, + gap: 6, + }, + segment: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + borderRadius: 14, + paddingVertical: 10, + }, + segmentLabel: { + fontSize: 14, + fontWeight: '600', + }, + segmentBadge: { + minWidth: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 6, + }, + segmentBadgeText: { + fontSize: 12, + fontWeight: '600', + }, + emptyState: { + alignItems: 'center', + paddingHorizontal: 24, + paddingVertical: 32, + borderRadius: 24, + gap: 16, + }, + emptyIllustration: { + width: 160, + height: 160, + resizeMode: 'contain', + }, + emptyTitle: { + textAlign: 'center', + fontSize: 18, + fontWeight: '600', + }, + emptySubtitle: { + textAlign: 'center', + fontSize: 14, + lineHeight: 20, + }, + primaryButton: { + marginTop: 8, + paddingVertical: 14, + paddingHorizontal: 32, + borderRadius: 22, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + primaryButtonText: { + fontSize: 16, + fontWeight: '600', + }, + cardsWrapper: { + gap: 16, + }, +}); diff --git a/components/DateSelector.tsx b/components/DateSelector.tsx index 7b70a24..f40c11f 100644 --- a/components/DateSelector.tsx +++ b/components/DateSelector.tsx @@ -58,6 +58,17 @@ export const DateSelector: React.FC = ({ const days = getMonthDaysZh(currentMonth); const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth); + // 判断当前选中的日期是否是今天 + const isSelectedDateToday = () => { + const today = dayjs(); + const selectedDate = days[selectedIndex]?.date; + + if (!selectedDate) return false; + + // 检查是否是同一天且在同一个月 + return selectedDate.isSame(today, 'day') && currentMonth.isSame(today, 'month'); + }; + // 滚动相关 const daysScrollRef = useRef(null); const [scrollWidth, setScrollWidth] = useState(0); @@ -191,20 +202,47 @@ export const DateSelector: React.FC = ({ } }; + const handleGoToday = () => { + const today = dayjs(); + setCurrentMonth(today); + const todayDays = getMonthDaysZh(today); + const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date()); + + if (newSelectedIndex !== -1) { + if (externalSelectedIndex === undefined) { + setInternalSelectedIndex(newSelectedIndex); + } + const todayDate = today.toDate(); + setPickerDate(todayDate); + onDateSelect?.(newSelectedIndex, todayDate); + } + }; + return ( {showMonthTitle && ( {monthTitle} - {showCalendarIcon && ( - - - - )} + + {!isSelectedDateToday() && ( + + 回到今天 + + )} + {showCalendarIcon && ( + + + + )} + )} @@ -305,9 +343,13 @@ const styles = StyleSheet.create({ monthTitleContainer: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'flex-start', + justifyContent: 'space-between', marginBottom: 8, }, + monthActions: { + flexDirection: 'row', + alignItems: 'center', + }, monthTitle: { fontSize: 20, fontWeight: '800', @@ -318,6 +360,18 @@ const styles = StyleSheet.create({ borderRadius: 6, marginLeft: 4 }, + todayButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 12, + backgroundColor: '#EEF2FF', + marginRight: 8, + }, + todayButtonText: { + fontSize: 12, + fontWeight: '600', + color: '#4C1D95', + }, daysContainer: { paddingBottom: 8, }, diff --git a/components/medication/MedicationCard.tsx b/components/medication/MedicationCard.tsx new file mode 100644 index 0000000..adf33ec --- /dev/null +++ b/components/medication/MedicationCard.tsx @@ -0,0 +1,301 @@ +import { ThemedText } from '@/components/ThemedText'; +import { Ionicons } from '@expo/vector-icons'; +import dayjs, { Dayjs } from 'dayjs'; +import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; +import { Image } from 'expo-image'; +import React from 'react'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; + +export type MedicationStatus = 'upcoming' | 'taken' | 'missed'; + +export type Medication = { + id: string; + name: string; + dosage: string; + scheduledTime: string; + frequency: string; + status: MedicationStatus; + image: any; +}; + +export type MedicationCardProps = { + medication: Medication; + colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors]; + selectedDate: Dayjs; +}; + +export function MedicationCard({ medication, colors, selectedDate }: MedicationCardProps) { + const scheduledDate = dayjs(`${selectedDate.format('YYYY-MM-DD')} ${medication.scheduledTime}`); + const timeDiffMinutes = scheduledDate.diff(dayjs(), 'minute'); + + const renderStatusBadge = () => { + if (medication.status === 'missed') { + return ( + + 已错过 + + ); + } + + if (medication.status === 'upcoming') { + if (timeDiffMinutes <= 0) { + return ( + + + 到服药时间 + + ); + } + + const hours = Math.floor(timeDiffMinutes / 60); + const minutes = timeDiffMinutes % 60; + const formatted = + hours > 0 ? `${hours}小时${minutes > 0 ? `${minutes}分钟` : ''}` : `${minutes}分钟`; + + return ( + + + 剩余 {formatted} + + ); + } + + return null; + }; + + const renderAction = () => { + if (medication.status === 'taken') { + return ( + + + 已服用 + + ); + } + + if (medication.status === 'missed') { + return ( + { + // 已错过的药物不能服用 + console.log('已错过的药物不能服用'); + }} + > + {isLiquidGlassAvailable() ? ( + + 已错过 + + ) : ( + + 已错过 + + )} + + ); + } + + return ( + { + // TODO: 实现服药功能 + console.log('服药功能待实现'); + }} + > + {isLiquidGlassAvailable() ? ( + + 立即服用 + + ) : ( + + 立即服用 + + )} + + ); + }; + + const statusChip = renderStatusBadge(); + + return ( + + + {statusChip ? {statusChip} : null} + + + + + + + + + + {medication.name} + + + {medication.dosage} + + + + + {medication.scheduledTime} | {medication.frequency} + + + {renderAction()} + + + + + + ); +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 26, + shadowOpacity: 0.08, + shadowOffset: { width: 0, height: 12 }, + shadowRadius: 24, + elevation: 2, + position: 'relative', + }, + cardSurface: { + borderRadius: 26, + overflow: 'hidden', + }, + cardBody: { + paddingHorizontal: 20, + paddingBottom: 20, + paddingTop: 28, + }, + cardContent: { + flexDirection: 'row', + alignItems: 'center', + gap: 20, + }, + thumbnailWrapper: { + width: 126, + height: 110, + }, + thumbnailSurface: { + flex: 1, + borderRadius: 22, + backgroundColor: '#F1F4FF', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + thumbnailImage: { + width: '80%', + height: '80%', + resizeMode: 'contain', + }, + infoSection: { + flex: 1, + }, + cardTitle: { + fontSize: 16, + fontWeight: '700', + }, + cardDosage: { + fontSize: 12, + marginTop: 4, + }, + scheduleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + cardSchedule: { + fontSize: 12, + }, + scheduleIcon: { + marginTop: -1, + }, + actionContainer: { + marginTop: 8, + }, + actionButton: { + alignSelf: 'stretch', + flexDirection: 'row', + alignItems: 'center', + gap: 6, + justifyContent: 'center', + height: 38, + borderRadius: 24, + overflow: 'hidden', + }, + actionButtonUpcoming: { + backgroundColor: '#1363FF', + }, + actionButtonTaken: { + backgroundColor: '#1FBF4B', + }, + actionButtonMissed: { + backgroundColor: '#9CA3AF', + }, + fallbackActionButton: { + borderWidth: 1, + borderColor: 'rgba(19, 99, 255, 0.3)', + backgroundColor: 'rgba(19, 99, 255, 0.9)', + }, + fallbackActionButtonMissed: { + borderWidth: 1, + borderColor: 'rgba(156, 163, 175, 0.3)', + backgroundColor: 'rgba(156, 163, 175, 0.9)', + }, + actionButtonText: { + fontSize: 14, + fontWeight: '700', + color: '#fff', + }, + actionButtonTextMissed: { + fontSize: 14, + fontWeight: '700', + color: '#fff', + }, + statusChipWrapper: { + position: 'absolute', + top: 0, + right: 0, + }, + statusChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 10, + height: 28, + borderBottomLeftRadius: 20, + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + backgroundColor: '#1363FF', + }, + statusChipUpcoming: { + backgroundColor: '#1363FF', + }, + statusChipMissed: { + backgroundColor: '#FF3B30', + }, + statusChipText: { + fontSize: 10, + fontWeight: '600', + color: '#fff', + }, +}); \ No newline at end of file diff --git a/components/ui/IconSymbol.tsx b/components/ui/IconSymbol.tsx index a68acf2..3e01b81 100644 --- a/components/ui/IconSymbol.tsx +++ b/components/ui/IconSymbol.tsx @@ -23,6 +23,8 @@ const MAPPING = { 'trophy.fill': 'emoji-events', 'timer': 'timer', 'person.fill': 'person', + 'plus': 'add', + 'pills.fill': 'medication', 'person.3.fill': 'people', 'message.fill': 'message', 'info.circle': 'info', diff --git a/constants/Routes.ts b/constants/Routes.ts index 9898ef5..8659d46 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -4,6 +4,7 @@ export const ROUTES = { TAB_EXPLORE: '/explore', TAB_COACH: '/coach', TAB_STATISTICS: '/statistics', + TAB_MEDICATIONS: '/medications', TAB_CHALLENGES: '/challenges', TAB_PERSONAL: '/personal', TAB_FASTING: '/fasting',