Files
digital-pilates/components/medication/MedicationCard.tsx
richarjiang 2dca3253e6 feat(i18n): 实现应用国际化支持,添加中英文翻译
- 为所有UI组件添加国际化支持,替换硬编码文本
- 新增useI18n钩子函数统一管理翻译
- 完善中英文翻译资源,覆盖统计、用药、通知设置等模块
- 优化Tab布局使用翻译键值替代静态文本
- 更新药品管理、个人资料编辑等页面的多语言支持
2025-11-13 11:09:55 +08:00

356 lines
10 KiB
TypeScript

import { ThemedText } from '@/components/ThemedText';
import { useAppDispatch } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
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;
onCelebrate?: () => void;
};
export function MedicationCard({ medication, colors, selectedDate, onOpenDetails, onCelebrate }: MedicationCardProps) {
const dispatch = useAppDispatch();
const { t } = useI18n();
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(
t('medications.card.earlyTakeAlert.title'),
t('medications.card.earlyTakeAlert.message', { time: medication.scheduledTime }),
[
{
text: t('medications.card.earlyTakeAlert.cancel'),
style: 'cancel',
onPress: () => {
// 用户取消,不执行任何操作
console.log('用户取消提前服药');
},
},
{
text: t('medications.card.earlyTakeAlert.confirm'),
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();
onCelebrate?.();
// 可选:显示成功提示
// Alert.alert('服药成功', '已记录本次服药');
} catch (error) {
console.error('[MEDICATION_CARD] 服药操作失败', error);
Alert.alert(
t('medications.card.takeError.title'),
error instanceof Error ? error.message : t('medications.card.takeError.message'),
[{ text: t('medications.card.takeError.confirm') }]
);
} finally {
setIsSubmitting(false);
}
};
const renderStatusBadge = () => {
if (medication.status === 'missed') {
return (
<View style={[styles.statusChip, styles.statusChipMissed]}>
<ThemedText style={styles.statusChipText}>{t('medications.card.status.missed')}</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}>{t('medications.card.status.timeToTake')}</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}>{t('medications.card.status.remaining', { time: 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}>{t('medications.card.action.taken')}</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 ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')}
</ThemedText>
</GlassView>
) : (
<View style={[styles.actionButton, styles.actionButtonUpcoming, styles.fallbackActionButton]}>
<ThemedText style={styles.actionButtonText}>
{isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')}
</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',
},
});