Files
digital-pilates/app/medications/manage-medications.tsx
richarjiang 81a6e43d7c feat(ui): 为药品管理页面添加渐变背景和装饰性元素
- 在药品详情页和管理页面添加线性渐变背景
- 增加装饰性圆圈元素提升视觉效果
- 为添加按钮应用玻璃效果(当可用时)
- 简化InfoCard组件,移除玻璃效果逻辑
- 统一页面视觉风格,提升用户体验
2025-11-11 17:39:52 +08:00

496 lines
14 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 { 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 { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
fetchMedications,
selectMedications,
selectMedicationsLoading,
updateMedicationAction,
} from '@/store/medicationsSlice';
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 FORM_LABELS: Record<MedicationForm, string> = {
capsule: '胶囊',
pill: '药片',
injection: '注射',
spray: '喷雾',
drop: '滴剂',
syrup: '糖浆',
other: '其他',
};
const FILTER_CONFIG: Array<{ key: FilterType; label: string }> = [
{ key: 'all', label: '全部' },
{ key: 'active', label: '进行中' },
{ key: 'inactive', label: '已停用' },
];
const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.png');
export default function ManageMedicationsScreen() {
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 [activeFilter, setActiveFilter] = useState<FilterType>('all');
const [pendingMedicationId, setPendingMedicationId] = useState<string | null>(null);
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;
try {
setPendingMedicationId(medication.id);
await dispatch(
updateMedicationAction({
id: medication.id,
isActive: nextValue,
})
).unwrap();
} catch (error) {
console.error('更新药物状态失败', error);
Alert.alert('操作失败', '切换药物状态时发生问题,请稍后重试。');
} finally {
setPendingMedicationId(null);
}
},
[dispatch, pendingMedicationId]
);
// 创建独立的药品卡片组件,使用 React.memo 优化渲染
const MedicationCard = React.memo(({ medication, onPress }: { medication: Medication; onPress: () => void }) => {
const dosageLabel = `${medication.dosageValue} ${medication.dosageUnit || ''} ${FORM_LABELS[medication.form] ?? ''}`.trim();
const frequencyLabel = `${medication.repeatPattern === 'daily' ? '每日' : medication.repeatPattern === 'weekly' ? '每周' : '自定义'} | ${dosageLabel}`;
const startDateLabel = dayjs(medication.startDate).isValid()
? dayjs(medication.startDate).format('M月D日')
: '未知日期';
const reminderLabel = medication.medicationTimes?.length
? medication.medicationTimes.join('、')
: `${medication.timesPerDay} 次/日`;
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 }]}>
{`开始于 ${startDateLabel} 提醒:${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="药品管理"
onBack={() => router.back()}
variant="minimal"
transparent
/>
<View style={{ paddingTop: safeAreaTop }} />
<ScrollView
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + 32 },
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.pageHeader}>
<View>
<ThemedText style={styles.title}></ThemedText>
<ThemedText style={[styles.subtitle, { color: colors.textMuted }]}>
</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 }]}>
{FILTER_CONFIG.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}>...</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}></ThemedText>
<ThemedText style={[styles.emptySubtitle, { color: colors.textSecondary }]}>
</ThemedText>
</View>
) : (
<View style={styles.list}>{filteredMedications.map(renderMedicationCard)}</View>
)}
</ScrollView>
</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: 26,
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, // 确保加载指示器显示在开关旁边
},
});