feat(medications): 添加用药管理功能
- 新增用药标签页,包含完整的用药记录界面 - 实现用药卡片组件,支持状态显示(已服用/未服用/已错过) - 增强日期选择器,添加"回到今天"快捷功能 - 添加用药相关的图标支持(pills.fill, plus) - 集成用药路由配置,支持标签页导航 该功能为用户提供完整的用药管理体验,包括用药记录、状态跟踪和日期筛选等核心功能。
This commit is contained in:
423
app/(tabs)/medications.tsx
Normal file
423
app/(tabs)/medications.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user