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

@@ -58,6 +58,17 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
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<ScrollView | null>(null);
const [scrollWidth, setScrollWidth] = useState(0);
@@ -191,20 +202,47 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
}
};
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 (
<View style={[styles.container, containerStyle]}>
{showMonthTitle && (
<View style={styles.monthTitleContainer}>
<Text style={styles.monthTitle}>{monthTitle}</Text>
{showCalendarIcon && (
<TouchableOpacity
onPress={openDatePicker}
style={styles.calendarIconButton}
activeOpacity={0.7}
>
<Ionicons name="calendar-outline" size={14} color="#666666" />
</TouchableOpacity>
)}
<View style={styles.monthActions}>
{!isSelectedDateToday() && (
<TouchableOpacity
onPress={handleGoToday}
style={styles.todayButton}
activeOpacity={0.8}
>
<Text style={styles.todayButtonText}></Text>
</TouchableOpacity>
)}
{showCalendarIcon && (
<TouchableOpacity
onPress={openDatePicker}
style={styles.calendarIconButton}
activeOpacity={0.7}
>
<Ionicons name="calendar-outline" size={14} color="#666666" />
</TouchableOpacity>
)}
</View>
</View>
)}
@@ -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,
},

View File

@@ -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 (
<View style={[styles.statusChip, styles.statusChipMissed]}>
<ThemedText style={styles.statusChipText}></ThemedText>
</View>
);
}
if (medication.status === 'upcoming') {
if (timeDiffMinutes <= 0) {
return (
<View style={[styles.statusChip, styles.statusChipUpcoming]}>
<Ionicons name="time-outline" size={14} color="#fff" />
<ThemedText style={styles.statusChipText}></ThemedText>
</View>
);
}
const hours = Math.floor(timeDiffMinutes / 60);
const minutes = timeDiffMinutes % 60;
const formatted =
hours > 0 ? `${hours}小时${minutes > 0 ? `${minutes}分钟` : ''}` : `${minutes}分钟`;
return (
<View style={[styles.statusChip, styles.statusChipUpcoming]}>
<Ionicons name="time-outline" size={14} color="#fff" />
<ThemedText style={styles.statusChipText}> {formatted}</ThemedText>
</View>
);
}
return null;
};
const renderAction = () => {
if (medication.status === 'taken') {
return (
<View style={[styles.actionButton, styles.actionButtonTaken]}>
<Ionicons name="checkmark-circle" size={18} color="#fff" />
<ThemedText style={styles.actionButtonText}></ThemedText>
</View>
);
}
if (medication.status === 'missed') {
return (
<TouchableOpacity
activeOpacity={1}
disabled={true}
onPress={() => {
// 已错过的药物不能服用
console.log('已错过的药物不能服用');
}}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={[styles.actionButton, styles.actionButtonMissed]}
glassEffectStyle="clear"
tintColor="rgba(156, 163, 175, 0.3)"
isInteractive={false}
>
<ThemedText style={styles.actionButtonTextMissed}></ThemedText>
</GlassView>
) : (
<View style={[styles.actionButton, styles.actionButtonMissed, styles.fallbackActionButtonMissed]}>
<ThemedText style={styles.actionButtonTextMissed}></ThemedText>
</View>
)}
</TouchableOpacity>
);
}
return (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => {
// TODO: 实现服药功能
console.log('服药功能待实现');
}}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={[styles.actionButton, styles.actionButtonUpcoming]}
glassEffectStyle="clear"
tintColor="rgba(19, 99, 255, 0.3)"
isInteractive={true}
>
<ThemedText style={styles.actionButtonText}></ThemedText>
</GlassView>
) : (
<View style={[styles.actionButton, styles.actionButtonUpcoming, styles.fallbackActionButton]}>
<ThemedText style={styles.actionButtonText}></ThemedText>
</View>
)}
</TouchableOpacity>
);
};
const statusChip = renderStatusBadge();
return (
<View style={[styles.card, { shadowColor: colors.text }]}>
<View style={[styles.cardSurface, { backgroundColor: colors.surface }]}>
{statusChip ? <View style={styles.statusChipWrapper}>{statusChip}</View> : null}
<View style={styles.cardBody}>
<View style={styles.cardContent}>
<View style={styles.thumbnailWrapper}>
<View style={styles.thumbnailSurface}>
<Image source={medication.image} style={styles.thumbnailImage} />
</View>
</View>
<View style={styles.infoSection}>
<ThemedText style={[styles.cardTitle, { color: colors.text }]}>
{medication.name}
</ThemedText>
<ThemedText style={[styles.cardDosage, { color: colors.textSecondary }]}>
{medication.dosage}
</ThemedText>
<View style={styles.scheduleRow}>
<Ionicons
name="time-outline"
size={14}
color={colors.textSecondary}
style={styles.scheduleIcon}
/>
<ThemedText style={[styles.cardSchedule, { color: colors.textSecondary }]}>
{medication.scheduledTime} | {medication.frequency}
</ThemedText>
</View>
<View style={styles.actionContainer}>{renderAction()}</View>
</View>
</View>
</View>
</View>
</View>
);
}
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',
},
});

View File

@@ -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',