- 新增用药标签页,包含完整的用药记录界面 - 实现用药卡片组件,支持状态显示(已服用/未服用/已错过) - 增强日期选择器,添加"回到今天"快捷功能 - 添加用药相关的图标支持(pills.fill, plus) - 集成用药路由配置,支持标签页导航 该功能为用户提供完整的用药管理体验,包括用药记录、状态跟踪和日期筛选等核心功能。
424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
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,
|
||
},
|
||
});
|