- 为所有UI组件添加国际化支持,替换硬编码文本 - 新增useI18n钩子函数统一管理翻译 - 完善中英文翻译资源,覆盖统计、用药、通知设置等模块 - 优化Tab布局使用翻译键值替代静态文本 - 更新药品管理、个人资料编辑等页面的多语言支持
555 lines
17 KiB
TypeScript
555 lines
17 KiB
TypeScript
import { ThemedText } from '@/components/ThemedText';
|
|
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
|
import { IconSymbol } from '@/components/ui/IconSymbol';
|
|
import { Colors } from '@/constants/Colors';
|
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
|
import { useI18n } from '@/hooks/useI18n';
|
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
|
import {
|
|
fetchMedications,
|
|
selectMedications,
|
|
selectMedicationsLoading,
|
|
updateMedicationAction,
|
|
} from '@/store/medicationsSlice';
|
|
import { selectUserProfile } from '@/store/userSlice';
|
|
import type { Medication, MedicationForm } from '@/types/medication';
|
|
import { useFocusEffect } from '@react-navigation/native';
|
|
import dayjs from 'dayjs';
|
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
|
import { Image } from 'expo-image';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { router } from 'expo-router';
|
|
import React, { useCallback, useMemo, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Switch,
|
|
TouchableOpacity,
|
|
View,
|
|
} from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
|
type FilterType = 'all' | 'active' | 'inactive';
|
|
|
|
// 这些常量将在组件内部定义,以便使用翻译函数
|
|
|
|
const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.png');
|
|
|
|
export default function ManageMedicationsScreen() {
|
|
const { t } = useI18n();
|
|
const dispatch = useAppDispatch();
|
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
|
const colors = Colors[theme];
|
|
const safeAreaTop = useSafeAreaTop();
|
|
const insets = useSafeAreaInsets();
|
|
const medications = useAppSelector(selectMedications);
|
|
const loading = useAppSelector(selectMedicationsLoading);
|
|
const userProfile = useAppSelector(selectUserProfile);
|
|
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
|
const [pendingMedicationId, setPendingMedicationId] = useState<string | null>(null);
|
|
const [deactivateSheetVisible, setDeactivateSheetVisible] = useState(false);
|
|
const [medicationToDeactivate, setMedicationToDeactivate] = useState<Medication | null>(null);
|
|
const [deactivateLoading, setDeactivateLoading] = useState(false);
|
|
|
|
const listLoading = loading.medications && medications.length === 0;
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
dispatch(fetchMedications());
|
|
}, [dispatch])
|
|
);
|
|
|
|
// 优化:使用更精确的依赖项,只有当药品数量或激活状态改变时才重新计算
|
|
const medicationsHash = useMemo(() => {
|
|
return medications.map(m => `${m.id}-${m.isActive}`).join('|');
|
|
}, [medications]);
|
|
|
|
const counts = useMemo<Record<FilterType, number>>(() => {
|
|
const active = medications.filter((med) => med.isActive).length;
|
|
const inactive = medications.length - active;
|
|
return {
|
|
all: medications.length,
|
|
active,
|
|
inactive,
|
|
};
|
|
}, [medicationsHash]);
|
|
|
|
const filteredMedications = useMemo(() => {
|
|
switch (activeFilter) {
|
|
case 'active':
|
|
return medications.filter((med) => med.isActive);
|
|
case 'inactive':
|
|
return medications.filter((med) => !med.isActive);
|
|
default:
|
|
return medications;
|
|
}
|
|
}, [activeFilter, medicationsHash]);
|
|
|
|
const handleToggleMedication = useCallback(
|
|
async (medication: Medication, nextValue: boolean) => {
|
|
if (pendingMedicationId) return;
|
|
|
|
// 如果是关闭激活状态,显示确认弹窗
|
|
if (!nextValue) {
|
|
setMedicationToDeactivate(medication);
|
|
setDeactivateSheetVisible(true);
|
|
return;
|
|
}
|
|
|
|
// 如果是开启激活状态,直接执行
|
|
try {
|
|
setPendingMedicationId(medication.id);
|
|
await dispatch(
|
|
updateMedicationAction({
|
|
id: medication.id,
|
|
isActive: nextValue,
|
|
})
|
|
).unwrap();
|
|
} catch (error) {
|
|
console.error('更新药物状态失败', error);
|
|
Alert.alert(t('medications.manage.toggleError.title'), t('medications.manage.toggleError.message'));
|
|
} finally {
|
|
setPendingMedicationId(null);
|
|
}
|
|
},
|
|
[dispatch, pendingMedicationId]
|
|
);
|
|
|
|
const handleDeactivateMedication = useCallback(async () => {
|
|
if (!medicationToDeactivate || deactivateLoading) return;
|
|
|
|
try {
|
|
setDeactivateLoading(true);
|
|
setDeactivateSheetVisible(false); // 立即关闭确认对话框
|
|
|
|
await dispatch(
|
|
updateMedicationAction({
|
|
id: medicationToDeactivate.id,
|
|
isActive: false,
|
|
})
|
|
).unwrap();
|
|
} catch (error) {
|
|
console.error('停用药物失败', error);
|
|
Alert.alert(t('medications.manage.deactivate.error.title'), t('medications.manage.deactivate.error.message'));
|
|
} finally {
|
|
setDeactivateLoading(false);
|
|
setMedicationToDeactivate(null);
|
|
}
|
|
}, [dispatch, medicationToDeactivate, deactivateLoading]);
|
|
|
|
// 创建独立的药品卡片组件,使用 React.memo 优化渲染
|
|
const MedicationCard = React.memo(({ medication, onPress }: { medication: Medication; onPress: () => void }) => {
|
|
// 使用翻译函数获取剂型标签
|
|
const FORM_LABELS: Record<MedicationForm, string> = {
|
|
capsule: t('medications.manage.formLabels.capsule'),
|
|
pill: t('medications.manage.formLabels.pill'),
|
|
injection: t('medications.manage.formLabels.injection'),
|
|
spray: t('medications.manage.formLabels.spray'),
|
|
drop: t('medications.manage.formLabels.drop'),
|
|
syrup: t('medications.manage.formLabels.syrup'),
|
|
other: t('medications.manage.formLabels.other'),
|
|
};
|
|
|
|
const dosageLabel = `${medication.dosageValue} ${medication.dosageUnit || ''} ${FORM_LABELS[medication.form] ?? ''}`.trim();
|
|
const frequencyLabel = `${medication.repeatPattern === 'daily' ? t('medications.manage.frequency.daily') : medication.repeatPattern === 'weekly' ? t('medications.manage.frequency.weekly') : t('medications.manage.frequency.custom')} | ${dosageLabel}`;
|
|
const startDateLabel = dayjs(medication.startDate).isValid()
|
|
? dayjs(medication.startDate).format('M月D日')
|
|
: t('medications.manage.unknownDate');
|
|
const reminderLabel = medication.medicationTimes?.length
|
|
? medication.medicationTimes.join('、')
|
|
: `${medication.timesPerDay} ${t('medications.manage.cardMeta.reminderNotSet')}`;
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
style={[styles.card, { backgroundColor: colors.surface }]}
|
|
activeOpacity={0.9}
|
|
onPress={onPress}
|
|
>
|
|
<View style={styles.cardInfo}>
|
|
<Image
|
|
source={medication.photoUrl ? { uri: medication.photoUrl } : DEFAULT_IMAGE}
|
|
style={styles.cardImage}
|
|
contentFit="cover"
|
|
/>
|
|
<View style={styles.cardTexts}>
|
|
<ThemedText style={styles.cardTitle}>{medication.name}</ThemedText>
|
|
<ThemedText style={[styles.cardMeta, { color: colors.textSecondary }]}>
|
|
{frequencyLabel}
|
|
</ThemedText>
|
|
<ThemedText style={[styles.cardMeta, { color: colors.textMuted }]}>
|
|
{t('medications.manage.cardMeta', { date: startDateLabel, reminder: reminderLabel })}
|
|
</ThemedText>
|
|
</View>
|
|
</View>
|
|
<View style={styles.switchContainer}>
|
|
<Switch
|
|
value={medication.isActive}
|
|
onValueChange={(value) => handleToggleMedication(medication, value)}
|
|
disabled={pendingMedicationId === medication.id}
|
|
trackColor={{ false: '#D9D9D9', true: colors.primary }}
|
|
thumbColor={medication.isActive ? '#fff' : '#fff'}
|
|
ios_backgroundColor="#D9D9D9"
|
|
/>
|
|
{pendingMedicationId === medication.id && (
|
|
<ActivityIndicator
|
|
size="small"
|
|
color={colors.primary}
|
|
style={styles.switchLoading}
|
|
/>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
}, (prevProps, nextProps) => {
|
|
// 自定义比较函数,只有当药品的 isActive 状态或 ID 改变时才重新渲染
|
|
return (
|
|
prevProps.medication.id === nextProps.medication.id &&
|
|
prevProps.medication.isActive === nextProps.medication.isActive &&
|
|
prevProps.medication.name === nextProps.medication.name &&
|
|
prevProps.medication.photoUrl === nextProps.medication.photoUrl
|
|
);
|
|
});
|
|
|
|
MedicationCard.displayName = 'MedicationCard';
|
|
|
|
const handleOpenMedicationDetails = useCallback((medicationId: string) => {
|
|
router.push({
|
|
pathname: '/medications/[medicationId]',
|
|
params: { medicationId },
|
|
});
|
|
}, []);
|
|
|
|
const renderMedicationCard = useCallback(
|
|
(medication: Medication) => {
|
|
return (
|
|
<MedicationCard
|
|
key={medication.id}
|
|
medication={medication}
|
|
onPress={() => handleOpenMedicationDetails(medication.id)}
|
|
/>
|
|
);
|
|
},
|
|
[handleToggleMedication, pendingMedicationId, colors, handleOpenMedicationDetails]
|
|
);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{/* 背景渐变 */}
|
|
<LinearGradient
|
|
colors={['#f5e5fbff', '#edf4f4ff', '#f7f8f8ff']}
|
|
style={styles.gradientBackground}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 0, y: 1 }}
|
|
/>
|
|
|
|
{/* 装饰性圆圈 */}
|
|
<View style={styles.decorativeCircle1} />
|
|
<View style={styles.decorativeCircle2} />
|
|
|
|
<HeaderBar
|
|
title={t('medications.manage.title')}
|
|
onBack={() => router.back()}
|
|
variant="minimal"
|
|
transparent
|
|
/>
|
|
|
|
<ScrollView
|
|
contentContainerStyle={[
|
|
styles.content,
|
|
{
|
|
paddingTop: safeAreaTop , // HeaderBar高度 + 额外间距
|
|
paddingBottom: insets.bottom + 32
|
|
},
|
|
]}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<View style={styles.pageHeader}>
|
|
<View>
|
|
<ThemedText style={styles.title}>{t('medications.greeting', { name: userProfile.name || '朋友' })}</ThemedText>
|
|
<ThemedText style={[styles.subtitle, { color: colors.textMuted }]}>
|
|
{t('medications.manage.subtitle')}
|
|
</ThemedText>
|
|
</View>
|
|
<TouchableOpacity
|
|
activeOpacity={0.85}
|
|
onPress={() => router.push('/medications/add-medication')}
|
|
>
|
|
{isLiquidGlassAvailable() ? (
|
|
<GlassView
|
|
style={styles.addButton}
|
|
glassEffectStyle="clear"
|
|
tintColor="rgba(255, 255, 255, 0.3)"
|
|
isInteractive={true}
|
|
>
|
|
<IconSymbol name="plus" size={20} color="#333" />
|
|
</GlassView>
|
|
) : (
|
|
<View style={[styles.addButton, styles.fallbackAddButton]}>
|
|
<IconSymbol name="plus" size={20} color="#333" />
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View style={[styles.segmented, { backgroundColor: colors.surface }]}>
|
|
{[
|
|
{ key: 'all' as FilterType, label: t('medications.manage.filters.all') },
|
|
{ key: 'active' as FilterType, label: t('medications.manage.filters.active') },
|
|
{ key: 'inactive' as FilterType, label: t('medications.manage.filters.inactive') },
|
|
].map((filter) => {
|
|
const isActive = filter.key === activeFilter;
|
|
return (
|
|
<TouchableOpacity
|
|
key={filter.key}
|
|
style={[
|
|
styles.segmentButton,
|
|
isActive && { backgroundColor: colors.primary },
|
|
]}
|
|
activeOpacity={0.85}
|
|
onPress={() => setActiveFilter(filter.key)}
|
|
>
|
|
<ThemedText
|
|
style={[
|
|
styles.segmentLabel,
|
|
{ color: isActive ? colors.onPrimary : colors.textSecondary },
|
|
]}
|
|
>
|
|
{filter.label}
|
|
</ThemedText>
|
|
<View
|
|
style={[
|
|
styles.segmentBadge,
|
|
{
|
|
backgroundColor: isActive
|
|
? colors.onPrimary
|
|
: `${colors.primary}15`,
|
|
},
|
|
]}
|
|
>
|
|
<ThemedText
|
|
style={[
|
|
styles.segmentBadgeLabel,
|
|
{ color: isActive ? colors.primary : colors.textSecondary },
|
|
]}
|
|
>
|
|
{counts[filter.key] ?? 0}
|
|
</ThemedText>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{listLoading ? (
|
|
<View style={[styles.loading, { backgroundColor: colors.surface }]}>
|
|
<ActivityIndicator color={colors.primary} />
|
|
<ThemedText style={styles.loadingText}>{t('medications.manage.loading')}</ThemedText>
|
|
</View>
|
|
) : filteredMedications.length === 0 ? (
|
|
<View style={[styles.empty, { backgroundColor: colors.surface }]}>
|
|
<Image source={DEFAULT_IMAGE} style={styles.emptyImage} contentFit="contain" />
|
|
<ThemedText style={styles.emptyTitle}>{t('medications.manage.empty.title')}</ThemedText>
|
|
<ThemedText style={[styles.emptySubtitle, { color: colors.textSecondary }]}>
|
|
{t('medications.manage.empty.subtitle')}
|
|
</ThemedText>
|
|
</View>
|
|
) : (
|
|
<View style={styles.list}>{filteredMedications.map(renderMedicationCard)}</View>
|
|
)}
|
|
</ScrollView>
|
|
|
|
{/* 停用药品确认弹窗 */}
|
|
{medicationToDeactivate ? (
|
|
<ConfirmationSheet
|
|
visible={deactivateSheetVisible}
|
|
onClose={() => {
|
|
setDeactivateSheetVisible(false);
|
|
setMedicationToDeactivate(null);
|
|
}}
|
|
onConfirm={handleDeactivateMedication}
|
|
title={t('medications.manage.deactivate.title', { name: medicationToDeactivate.name })}
|
|
description={t('medications.manage.deactivate.description')}
|
|
confirmText={t('medications.manage.deactivate.confirm')}
|
|
cancelText={t('medications.manage.deactivate.cancel')}
|
|
destructive
|
|
loading={deactivateLoading}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
position: 'relative',
|
|
},
|
|
gradientBackground: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
},
|
|
decorativeCircle1: {
|
|
position: 'absolute',
|
|
top: 40,
|
|
right: 20,
|
|
width: 60,
|
|
height: 60,
|
|
borderRadius: 30,
|
|
backgroundColor: '#0EA5E9',
|
|
opacity: 0.1,
|
|
},
|
|
decorativeCircle2: {
|
|
position: 'absolute',
|
|
bottom: -15,
|
|
left: -15,
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
backgroundColor: '#0EA5E9',
|
|
opacity: 0.05,
|
|
},
|
|
content: {
|
|
paddingHorizontal: 20,
|
|
gap: 20,
|
|
},
|
|
pageHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
title: {
|
|
fontSize: 24,
|
|
fontWeight: '600',
|
|
},
|
|
subtitle: {
|
|
marginTop: 6,
|
|
fontSize: 14,
|
|
},
|
|
addButton: {
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 22,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
},
|
|
fallbackAddButton: {
|
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255, 255, 255, 0.3)',
|
|
},
|
|
segmented: {
|
|
flexDirection: 'row',
|
|
padding: 6,
|
|
borderRadius: 20,
|
|
gap: 6,
|
|
},
|
|
segmentButton: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderRadius: 16,
|
|
paddingVertical: 10,
|
|
gap: 8,
|
|
},
|
|
segmentLabel: {
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
},
|
|
segmentBadge: {
|
|
minWidth: 28,
|
|
paddingHorizontal: 8,
|
|
height: 24,
|
|
borderRadius: 12,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
segmentBadgeLabel: {
|
|
fontSize: 12,
|
|
fontWeight: '700',
|
|
},
|
|
list: {
|
|
gap: 14,
|
|
},
|
|
card: {
|
|
borderRadius: 22,
|
|
padding: 14,
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
shadowColor: '#000',
|
|
shadowOpacity: 0.05,
|
|
shadowRadius: 6,
|
|
shadowOffset: { width: 0, height: 4 },
|
|
elevation: 1,
|
|
},
|
|
cardInfo: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
flex: 1,
|
|
},
|
|
cardImage: {
|
|
width: 52,
|
|
height: 52,
|
|
borderRadius: 16,
|
|
backgroundColor: '#F2F2F2',
|
|
},
|
|
cardTexts: {
|
|
flex: 1,
|
|
gap: 4,
|
|
},
|
|
cardTitle: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
},
|
|
cardMeta: {
|
|
fontSize: 13,
|
|
},
|
|
loading: {
|
|
borderRadius: 22,
|
|
paddingVertical: 32,
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
},
|
|
loadingText: {
|
|
fontSize: 14,
|
|
},
|
|
empty: {
|
|
borderRadius: 22,
|
|
paddingVertical: 32,
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
paddingHorizontal: 16,
|
|
},
|
|
emptyImage: {
|
|
width: 120,
|
|
height: 120,
|
|
},
|
|
emptyTitle: {
|
|
fontSize: 18,
|
|
fontWeight: '600',
|
|
},
|
|
emptySubtitle: {
|
|
fontSize: 14,
|
|
textAlign: 'center',
|
|
},
|
|
switchContainer: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
position: 'relative',
|
|
},
|
|
switchLoading: {
|
|
position: 'absolute',
|
|
marginLeft: 30, // 确保加载指示器显示在开关旁边
|
|
},
|
|
});
|