- 新增底部标签栏配置页面,支持切换标签显示/隐藏和恢复默认设置 - 实现已服用药物的堆叠卡片展示,优化药物列表视觉层次 - 集成Redux状态管理底部标签栏配置,支持本地持久化 - 优化个人中心页面背景渐变效果,移除装饰性圆圈元素 - 更新启动页和应用图标为新的品牌视觉 - 药物详情页AI分析加载动画替换为Lottie动画 - 调整药物卡片圆角半径提升视觉一致性 - 新增多语言支持(中英文)用于标签栏配置界面 主要改进: 1. 用户可以自定义底部导航栏显示内容 2. 已完成的药物以堆叠形式展示,节省空间 3. 配置数据通过AsyncStorage持久化保存 4. 支持默认配置恢复功能
482 lines
14 KiB
TypeScript
482 lines
14 KiB
TypeScript
import { ThemedText } from '@/components/ThemedText';
|
|
import { useAppDispatch } from '@/hooks/redux';
|
|
import { useI18n } from '@/hooks/useI18n';
|
|
import { skipMedicationAction, 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 handleSkipMedication = async () => {
|
|
// 检查 recordId 是否存在
|
|
if (!medication.recordId || isSubmitting) {
|
|
return;
|
|
}
|
|
|
|
// 显示二次确认弹窗
|
|
Alert.alert(
|
|
t('medications.card.skipAlert.title'),
|
|
t('medications.card.skipAlert.message'),
|
|
[
|
|
{
|
|
text: t('medications.card.skipAlert.cancel'),
|
|
style: 'cancel',
|
|
onPress: () => {
|
|
console.log('用户取消跳过');
|
|
},
|
|
},
|
|
{
|
|
text: t('medications.card.skipAlert.confirm'),
|
|
style: 'destructive',
|
|
onPress: () => {
|
|
executeSkipMedication(medication.recordId!);
|
|
},
|
|
},
|
|
]
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 执行跳过操作
|
|
*/
|
|
const executeSkipMedication = async (recordId: string) => {
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
// 调用 Redux action 标记为已跳过
|
|
await dispatch(skipMedicationAction({
|
|
recordId: recordId,
|
|
})).unwrap();
|
|
|
|
// 可选:显示成功提示
|
|
// Alert.alert('跳过成功', '已跳过本次用药');
|
|
} catch (error) {
|
|
console.error('[MEDICATION_CARD] 跳过操作失败', error);
|
|
Alert.alert(
|
|
t('medications.card.skipError.title'),
|
|
error instanceof Error ? error.message : t('medications.card.skipError.message'),
|
|
[{ text: t('medications.card.skipError.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>
|
|
);
|
|
}
|
|
|
|
// 已跳过状态
|
|
if (medication.status === 'skipped') {
|
|
return (
|
|
<View style={[styles.actionButton, styles.actionButtonSkipped]}>
|
|
<Ionicons name="close-circle" size={18} color="#fff" />
|
|
<ThemedText style={styles.actionButtonText}>{t('medications.card.action.skipped')}</ThemedText>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// 待服用或已错过状态,显示操作按钮
|
|
return (
|
|
<View style={styles.actionButtonsRow}>
|
|
{/* 跳过按钮 */}
|
|
<TouchableOpacity
|
|
activeOpacity={0.7}
|
|
onPress={handleSkipMedication}
|
|
disabled={isSubmitting}
|
|
style={styles.skipButtonWrapper}
|
|
>
|
|
{isLiquidGlassAvailable() ? (
|
|
<GlassView
|
|
style={[styles.actionButton, styles.actionButtonSkip]}
|
|
glassEffectStyle="clear"
|
|
tintColor="rgba(156, 163, 175, 0.2)"
|
|
isInteractive={!isSubmitting}
|
|
>
|
|
<ThemedText style={styles.actionButtonTextSkip}>
|
|
{t('medications.card.action.skip')}
|
|
</ThemedText>
|
|
</GlassView>
|
|
) : (
|
|
<View style={[styles.actionButton, styles.actionButtonSkip, styles.fallbackActionButtonSkip]}>
|
|
<ThemedText style={styles.actionButtonTextSkip}>
|
|
{t('medications.card.action.skip')}
|
|
</ThemedText>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
{/* 立即服用按钮 */}
|
|
<TouchableOpacity
|
|
activeOpacity={0.7}
|
|
onPress={handleTakeMedication}
|
|
disabled={isSubmitting}
|
|
style={styles.takeButtonWrapper}
|
|
>
|
|
{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>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
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: 24,
|
|
position: 'relative',
|
|
},
|
|
cardSurface: {
|
|
borderRadius: 24,
|
|
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: 24,
|
|
},
|
|
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,
|
|
},
|
|
actionButtonsRow: {
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
},
|
|
skipButtonWrapper: {
|
|
flex: 1,
|
|
},
|
|
takeButtonWrapper: {
|
|
flex: 2,
|
|
},
|
|
actionButton: {
|
|
alignSelf: 'stretch',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
justifyContent: 'center',
|
|
height: 38,
|
|
borderRadius: 10,
|
|
overflow: 'hidden',
|
|
},
|
|
actionButtonUpcoming: {
|
|
backgroundColor: '#1363FF',
|
|
},
|
|
actionButtonTaken: {
|
|
backgroundColor: '#1FBF4B',
|
|
},
|
|
actionButtonSkipped: {
|
|
backgroundColor: '#9CA3AF',
|
|
},
|
|
actionButtonSkip: {
|
|
backgroundColor: '#E5E7EB',
|
|
},
|
|
actionButtonMissed: {
|
|
backgroundColor: '#9CA3AF',
|
|
},
|
|
fallbackActionButton: {
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(19, 99, 255, 0.3)',
|
|
backgroundColor: 'rgba(19, 99, 255, 0.9)',
|
|
},
|
|
fallbackActionButtonSkip: {
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(156, 163, 175, 0.2)',
|
|
backgroundColor: 'rgba(229, 231, 235, 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',
|
|
},
|
|
actionButtonTextSkip: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
color: '#6B7280',
|
|
},
|
|
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',
|
|
},
|
|
});
|