添加了药物管理的核心功能,包括: - 药物列表展示和状态管理 - 添加新药物的完整流程 - 服药记录的创建和状态更新 - 药物管理界面,支持激活/停用操作 - Redux状态管理和API服务层 - 相关类型定义和辅助函数 主要文件: - app/(tabs)/medications.tsx - 主界面,集成Redux数据 - app/medications/add-medication.tsx - 添加药物流程 - app/medications/manage-medications.tsx - 药物管理界面 - store/medicationsSlice.ts - Redux状态管理 - services/medications.ts - API服务层 - types/medication.ts - 类型定义
364 lines
10 KiB
TypeScript
364 lines
10 KiB
TypeScript
import { ThemedText } from '@/components/ThemedText';
|
||
import { useAppDispatch } from '@/hooks/redux';
|
||
import { takeMedicationAction } from '@/store/medicationsSlice';
|
||
import type { MedicationDisplayItem } from '@/types/medication';
|
||
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, { useState } from 'react';
|
||
import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||
|
||
export type MedicationCardProps = {
|
||
medication: MedicationDisplayItem;
|
||
colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors];
|
||
selectedDate: Dayjs;
|
||
};
|
||
|
||
export function MedicationCard({ medication, colors, selectedDate }: MedicationCardProps) {
|
||
const dispatch = useAppDispatch();
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
|
||
const scheduledDate = dayjs(`${selectedDate.format('YYYY-MM-DD')} ${medication.scheduledTime}`);
|
||
const timeDiffMinutes = scheduledDate.diff(dayjs(), 'minute');
|
||
|
||
/**
|
||
* 处理服药操作
|
||
*/
|
||
const handleTakeMedication = async () => {
|
||
// 检查 recordId 是否存在
|
||
if (!medication.recordId || isSubmitting) {
|
||
return;
|
||
}
|
||
|
||
// 判断是否早于服药时间1小时以上
|
||
if (timeDiffMinutes > 60) {
|
||
// 显示二次确认弹窗
|
||
Alert.alert(
|
||
'尚未到服药时间',
|
||
`该用药计划在 ${medication.scheduledTime},现在还早于1小时以上。\n\n是否确认已服用此药物?`,
|
||
[
|
||
{
|
||
text: '取消',
|
||
style: 'cancel',
|
||
onPress: () => {
|
||
// 用户取消,不执行任何操作
|
||
console.log('用户取消提前服药');
|
||
},
|
||
},
|
||
{
|
||
text: '确认已服用',
|
||
style: 'default',
|
||
onPress: () => {
|
||
// 用户确认,执行服药逻辑
|
||
executeTakeMedication(medication.recordId!);
|
||
},
|
||
},
|
||
]
|
||
);
|
||
} else {
|
||
// 在正常时间范围内,直接执行服药逻辑
|
||
executeTakeMedication(medication.recordId);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 执行服药操作(提取公共逻辑)
|
||
*/
|
||
const executeTakeMedication = async (recordId: string) => {
|
||
setIsSubmitting(true);
|
||
|
||
try {
|
||
// 调用 Redux action 标记为已服用
|
||
await dispatch(takeMedicationAction({
|
||
recordId: recordId,
|
||
actualTime: new Date().toISOString(),
|
||
})).unwrap();
|
||
|
||
// 可选:显示成功提示
|
||
// Alert.alert('服药成功', '已记录本次服药');
|
||
} catch (error) {
|
||
console.error('[MEDICATION_CARD] 服药操作失败', error);
|
||
Alert.alert(
|
||
'操作失败',
|
||
error instanceof Error ? error.message : '记录服药时发生错误,请稍后重试',
|
||
[{ text: '确定' }]
|
||
);
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
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={handleTakeMedication}
|
||
disabled={isSubmitting}
|
||
>
|
||
{isLiquidGlassAvailable() ? (
|
||
<GlassView
|
||
style={[styles.actionButton, styles.actionButtonUpcoming]}
|
||
glassEffectStyle="clear"
|
||
tintColor="rgba(19, 99, 255, 0.3)"
|
||
isInteractive={!isSubmitting}
|
||
>
|
||
<ThemedText style={styles.actionButtonText}>
|
||
{isSubmitting ? '提交中...' : '立即服用'}
|
||
</ThemedText>
|
||
</GlassView>
|
||
) : (
|
||
<View style={[styles.actionButton, styles.actionButtonUpcoming, styles.fallbackActionButton]}>
|
||
<ThemedText style={styles.actionButtonText}>
|
||
{isSubmitting ? '提交中...' : '立即服用'}
|
||
</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',
|
||
},
|
||
}); |