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

该功能为用户提供完整的用药管理体验,包括用药记录、状态跟踪和日期筛选等核心功能。
2025-11-06 17:51:06 +08:00

424 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
},
});