feat(medications): 添加用药管理功能

- 新增用药标签页,包含完整的用药记录界面
- 实现用药卡片组件,支持状态显示(已服用/未服用/已错过)
- 增强日期选择器,添加"回到今天"快捷功能
- 添加用药相关的图标支持(pills.fill, plus)
- 集成用药路由配置,支持标签页导航

该功能为用户提供完整的用药管理体验,包括用药记录、状态跟踪和日期筛选等核心功能。
This commit is contained in:
richarjiang
2025-11-06 17:51:06 +08:00
parent a228280ca4
commit 3aafc50702
6 changed files with 798 additions and 10 deletions

View File

@@ -21,6 +21,7 @@ type TabConfig = {
const TAB_CONFIGS: Record<string, TabConfig> = {
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<string, string> = {
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() {
<Label></Label>
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="medications">
<Icon sf="pills.fill" drawable="custom_android_drawable" />
<Label></Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="fasting">
<Icon sf="timer" drawable="custom_android_drawable" />
<Label></Label>
@@ -198,6 +204,7 @@ export default function TabLayout() {
>
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
<Tabs.Screen name="medications" options={{ title: '用药' }} />
<Tabs.Screen name="fasting" options={{ title: '断食' }} />
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
<Tabs.Screen name="personal" options={{ title: '个人' }} />

423
app/(tabs)/medications.tsx Normal file
View File

@@ -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>(dayjs());
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
const [activeFilter, setActiveFilter] = useState<MedicationFilter>('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<string, Medication[]>;
}, []);
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 (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#edf4f4ff', '#f7f8f8ff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: insets.top + 24, paddingBottom: insets.bottom + 36 },
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<View>
<ThemedText style={styles.greeting}>{displayName}</ThemedText>
<ThemedText style={[styles.welcome, { color: colors.textMuted }]}>
</ThemedText>
</View>
</View>
<View style={styles.sectionSpacing}>
<DateSelector
selectedIndex={selectedDateIndex}
onDateSelect={(index, date) => {
setSelectedDate(dayjs(date));
setSelectedDateIndex(index);
}}
disableFutureDates
containerStyle={styles.dateSelectorContainer}
/>
</View>
<View style={styles.sectionSpacing}>
<ThemedText style={styles.sectionHeader}></ThemedText>
<View style={[styles.segmentedControl, { backgroundColor: colors.surface }]}>
{(['all', 'taken', 'missed'] as MedicationFilter[]).map((filter) => {
const isActive = activeFilter === filter;
const labelMap: Record<MedicationFilter, string> = {
all: '全部',
taken: '已服用',
missed: '未服用',
};
return (
<TouchableOpacity
key={filter}
onPress={() => setActiveFilter(filter)}
style={[
styles.segment,
isActive && { backgroundColor: colors.primary },
]}
>
<ThemedText
style={[
styles.segmentLabel,
{ color: isActive ? colors.onPrimary : colors.textSecondary },
]}
>
{labelMap[filter]}
</ThemedText>
<View
style={[
styles.segmentBadge,
{
backgroundColor: isActive ? colors.onPrimary : `${colors.primary}20`,
},
]}
>
<ThemedText
style={[
styles.segmentBadgeText,
{ color: isActive ? colors.primary : colors.textSecondary },
]}
>
{counts[filter]}
</ThemedText>
</View>
</TouchableOpacity>
);
})}
</View>
</View>
{emptyState ? (
<View style={[styles.emptyState, { backgroundColor: colors.surface }]}>
<Image
source={require('@/assets/images/task/ImageEmpty.png')}
style={styles.emptyIllustration}
contentFit="cover"
/>
<ThemedText style={styles.emptyTitle}></ThemedText>
<ThemedText style={[styles.emptySubtitle, { color: colors.textMuted }]}>
</ThemedText>
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: colors.primary }]}
>
<IconSymbol name="plus" size={18} color={colors.onPrimary} />
<ThemedText style={[styles.primaryButtonText, { color: colors.onPrimary }]}>
</ThemedText>
</TouchableOpacity>
</View>
) : (
<View style={styles.cardsWrapper}>
{filteredMedications.map((item) => (
<MedicationCard
key={item.id}
medication={item}
colors={colors}
selectedDate={selectedDate}
/>
))}
</View>
)}
</ScrollView>
</View>
);
}
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,
},
});