Files
digital-pilates/components/medication/MedicationCard.tsx
richarjiang 50525f82a1 feat(medications): 优化药品管理功能和登录流程
- 更新默认药品图片为专用图标
- 移除未使用的 loading 状态选择器
- 优化 Apple 登录按钮样式,支持毛玻璃效果和加载状态
- 添加登录成功后返回功能(shouldBack 参数)
- 药品详情页添加信息卡片点击交互
- 添加药品添加页面的登录状态检查
- 增强时间选择器错误处理和数据验证
- 修复药品图片显示逻辑,支持网络图片
- 优化药品卡片样式和布局
- 添加图片加载错误处理
2025-11-11 10:02:37 +08:00

352 lines
9.8 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 { 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, { useEffect, 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;
onOpenDetails?: (medication: MedicationDisplayItem) => void;
};
export function MedicationCard({ medication, colors, selectedDate, onOpenDetails }: MedicationCardProps) {
const dispatch = useAppDispatch();
const [isSubmitting, setIsSubmitting] = useState(false);
const [imageError, setImageError] = useState(false);
const scheduledDate = dayjs(`${selectedDate.format('YYYY-MM-DD')} ${medication.scheduledTime}`);
const timeDiffMinutes = scheduledDate.diff(dayjs(), 'minute');
// 当药品变化时重置图片错误状态
useEffect(() => {
setImageError(false);
}, [medication.id]);
/**
* 处理服药操作
*/
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>
);
}
// 只要没有服药,都可以显示立即服用
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 (
<TouchableOpacity
style={[styles.card, { shadowColor: colors.text }]}
activeOpacity={onOpenDetails ? 0.92 : 1}
onPress={() => onOpenDetails?.(medication)}
disabled={!onOpenDetails}
>
<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}
onError={() => setImageError(true)}
key={medication.id} // 重新渲染时重置状态
/>
</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>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: 18,
position: 'relative',
},
cardSurface: {
borderRadius: 18,
overflow: 'hidden',
},
cardBody: {
paddingHorizontal: 10,
paddingBottom: 10,
paddingTop: 10,
},
cardContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 20,
},
thumbnailWrapper: {
width: 148,
height: 110,
},
thumbnailSurface: {
flex: 1,
backgroundColor: '#F1F4FF',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: 18,
},
thumbnailImage: {
width: '70%',
height: '70%',
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: 10,
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',
},
});