Files
digital-pilates/app/(tabs)/medications.tsx
richarjiang 3db2d39a58 perf(store): 优化 selector 性能并移除未使用代码
- 使用 createSelector 和 useMemo 优化 medications 和 tabBarConfig 的 selector,避免不必要的重渲染
- 添加空数组常量 EMPTY_RECORDS_ARRAY,减少对象创建开销
- 移除 _layout.tsx 中未使用的路由配置
- 删除过时的通知实现文档
- 移除 pushNotificationManager 中未使用的 token 刷新监听器
- 禁用开发环境的后台任务调试工具初始化
2025-11-24 11:11:29 +08:00

610 lines
19 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 CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
import { DateSelector } from '@/components/DateSelector';
import { MedicationAddOptionsSheet } from '@/components/medication/MedicationAddOptionsSheet';
import { MedicationCard } from '@/components/medication/MedicationCard';
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
import { ThemedText } from '@/components/ThemedText';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
import { Colors } from '@/constants/Colors';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useVipService } from '@/hooks/useVipService';
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice';
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
import { getItemSync, setItemSync } from '@/utils/kvStore';
import { convertMedicationDataToWidget, refreshWidget, syncMedicationDataToWidget } from '@/utils/widgetDataSync';
import { useFocusEffect } from '@react-navigation/native';
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn';
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, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ScrollView,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
dayjs.locale('zh-cn');
// 本地存储键名:医疗免责声明已读状态
const MEDICAL_DISCLAIMER_READ_KEY = 'medical_disclaimer_read';
type MedicationFilter = 'all' | 'taken' | 'missed';
type ThemeColors = (typeof Colors)[keyof typeof Colors];
export default function MedicationsScreen() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const insets = useSafeAreaInsets();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colors: ThemeColors = Colors[theme];
const userProfile = useAppSelector((state) => state.user.profile);
const { ensureLoggedIn } = useAuthGuard();
const { checkServiceAccess } = useVipService();
const { openMembershipModal } = useMembershipModal();
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
const [activeFilter, setActiveFilter] = useState<MedicationFilter>('all');
const celebrationRef = useRef<CelebrationAnimationRef>(null);
const celebrationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isCelebrationVisible, setIsCelebrationVisible] = useState(false);
const [disclaimerVisible, setDisclaimerVisible] = useState(false);
const [addSheetVisible, setAddSheetVisible] = useState(false);
const [pendingAction, setPendingAction] = useState<'manual' | null>(null);
// 从 Redux 获取数据
const selectedKey = selectedDate.format('YYYY-MM-DD');
// 使用 useMemo 缓存 selector 实例,避免每次渲染都创建新的 selector
const medicationSelector = useMemo(
() => selectMedicationDisplayItemsByDate(selectedKey),
[selectedKey]
);
const medicationsForDay = useAppSelector(medicationSelector);
const handleOpenAddSheet = useCallback(() => {
setAddSheetVisible(true);
}, []);
const handleManualAdd = useCallback(() => {
const hasRead = getItemSync(MEDICAL_DISCLAIMER_READ_KEY);
setPendingAction('manual');
if (hasRead === 'true') {
setAddSheetVisible(false);
setPendingAction(null);
router.push('/medications/add-medication');
} else {
setAddSheetVisible(false);
setDisclaimerVisible(true);
}
}, []);
const handleAiRecognize = useCallback(async () => {
setAddSheetVisible(false);
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) return;
const access = checkServiceAccess();
if (!access.canUseService) {
openMembershipModal();
return;
}
router.push('/medications/ai-camera');
}, [checkServiceAccess, ensureLoggedIn, openMembershipModal, router]);
const handleDisclaimerConfirm = useCallback(() => {
// 用户同意免责声明后,记录已读状态,关闭弹窗并跳转到添加页面
setItemSync(MEDICAL_DISCLAIMER_READ_KEY, 'true');
setDisclaimerVisible(false);
if (pendingAction === 'manual') {
setPendingAction(null);
router.push('/medications/add-medication');
}
}, [pendingAction]);
const handleDisclaimerClose = useCallback(() => {
// 用户不接受免责声明,只关闭弹窗,不跳转,不记录已读状态
setDisclaimerVisible(false);
setPendingAction(null);
}, []);
const handleOpenMedicationManagement = useCallback(() => {
router.push('/medications/manage-medications');
}, []);
const handleOpenMedicationDetails = useCallback((medicationId: string) => {
router.push({
pathname: '/medications/[medicationId]',
params: { medicationId },
});
}, []);
const handleMedicationTakenCelebration = useCallback(() => {
if (celebrationTimerRef.current) {
clearTimeout(celebrationTimerRef.current);
}
setIsCelebrationVisible(true);
requestAnimationFrame(() => {
celebrationRef.current?.play();
});
celebrationTimerRef.current = setTimeout(() => {
setIsCelebrationVisible(false);
}, 2400);
}, []);
// 加载药物和记录数据
useEffect(() => {
dispatch(fetchMedications());
dispatch(fetchMedicationRecords({ date: selectedKey }));
}, [dispatch, selectedKey]);
useEffect(() => {
return () => {
if (celebrationTimerRef.current) {
clearTimeout(celebrationTimerRef.current);
}
};
}, []);
// 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据
useFocusEffect(
useCallback(() => {
// 重新安排药品通知并刷新数据
const refreshDataAndRescheduleNotifications = async () => {
try {
// 只获取一次药物数据,然后复用结果
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
// 获取药物记录
const recordsAction = await dispatch(fetchMedicationRecords({ date: selectedKey }));
// 同步数据到小组件(仅同步今天的)
const today = dayjs().format('YYYY-MM-DD');
const records = recordsAction.payload as any;
if (selectedKey === today && records?.records) {
const medicationData = convertMedicationDataToWidget(
records.records,
medications,
selectedKey
);
await syncMedicationDataToWidget(medicationData);
// 刷新小组件
await refreshWidget();
}
} catch (error) {
console.error('刷新数据或重新安排药品通知失败:', error);
}
};
refreshDataAndRescheduleNotifications();
}, [dispatch, selectedKey])
);
useEffect(() => {
setActiveFilter('all');
}, [selectedDate]);
// 为每个药物添加默认图片(如果没有图片)
const medicationsWithImages = useMemo(() => {
return medicationsForDay.map((med: any) => ({
...med,
image: med.image || require('@/assets/images/medicine/image-medicine.png'), // 默认使用瓶子图标
}));
}, [medicationsForDay]);
const filteredMedications = useMemo(() => {
if (activeFilter === 'all') {
return medicationsWithImages;
}
// "未服用" tab 包含 missed已错过和 upcoming待服用两种状态
if (activeFilter === 'missed') {
return medicationsWithImages.filter((item: any) =>
item.status === 'missed' || item.status === 'upcoming'
);
}
// 其他状态按原逻辑过滤
return medicationsWithImages.filter((item: any) => item.status === activeFilter);
}, [activeFilter, medicationsWithImages]);
const activeMedications = useMemo(() => {
if (activeFilter !== 'all') return filteredMedications;
return filteredMedications.filter((item: any) => item.status !== 'taken' && item.status !== 'skipped');
}, [activeFilter, filteredMedications]);
const completedMedications = useMemo(() => {
if (activeFilter !== 'all') return [];
return filteredMedications.filter((item: any) => item.status === 'taken' || item.status === 'skipped');
}, [activeFilter, filteredMedications]);
const counts = useMemo(() => {
const taken = medicationsWithImages.filter((item: any) => item.status === 'taken').length;
// "未服用"计数包含 missed已错过和 upcoming待服用
const missed = medicationsWithImages.filter((item: any) =>
item.status === 'missed' || item.status === 'upcoming'
).length;
return {
all: medicationsWithImages.length,
taken,
missed,
};
}, [medicationsWithImages]);
const displayName = userProfile.name?.trim() || DEFAULT_MEMBER_NAME;
const headerDateLabel = selectedDate.isSame(dayjs(), 'day')
? t('medications.dateFormats.today', { date: selectedDate.format('M月D日') })
: t('medications.dateFormats.other', { date: selectedDate.format('M月D日 dddd') });
const emptyState = filteredMedications.length === 0;
return (
<View style={styles.container}>
{isCelebrationVisible ? (
<CelebrationAnimation ref={celebrationRef} visible={isCelebrationVisible} />
) : null}
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#edf4f4ff', '#ffffff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: insets.top + 24, paddingBottom: insets.bottom + 36 },
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<View>
<ThemedText style={styles.greeting}>{t('medications.greeting', { name: displayName })}</ThemedText>
<ThemedText style={[styles.welcome, { color: colors.textMuted }]}>
{t('medications.welcome')}
</ThemedText>
</View>
<View style={styles.headerActions}>
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenMedicationManagement}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.headerAddButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<IconSymbol name="pills.fill" size={18} color="#333" />
</GlassView>
) : (
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
<IconSymbol name="pills.fill" size={18} color="#333" />
</View>
)}
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenAddSheet}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.headerAddButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<IconSymbol name="plus" size={18} color="#333" />
</GlassView>
) : (
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
<IconSymbol name="plus" size={18} color="#333" />
</View>
)}
</TouchableOpacity>
</View>
</View>
<View style={styles.sectionSpacing}>
<DateSelector
selectedIndex={selectedDateIndex}
onDateSelect={(index, date) => {
setSelectedDate(dayjs(date));
setSelectedDateIndex(index);
}}
disableFutureDates
containerStyle={styles.dateSelectorContainer}
/>
</View>
<View style={styles.sectionSpacing}>
<ThemedText style={styles.sectionHeader}>{t('medications.todayMedications')}</ThemedText>
<View style={[styles.segmentedControl, { backgroundColor: colors.surface }]}>
{(['all', 'taken', 'missed'] as MedicationFilter[]).map((filter) => {
const isActive = activeFilter === filter;
return (
<TouchableOpacity
key={filter}
onPress={() => setActiveFilter(filter)}
style={[
styles.segment,
isActive && { backgroundColor: colors.primary },
]}
>
<ThemedText
style={[
styles.segmentLabel,
{ color: isActive ? colors.onPrimary : colors.textSecondary },
]}
>
{t(`medications.filters.${filter}`)}
</ThemedText>
<View
style={[
styles.segmentBadge,
{
backgroundColor: isActive ? colors.onPrimary : `${colors.primary}20`,
},
]}
>
<ThemedText
style={[
styles.segmentBadgeText,
{ color: isActive ? colors.primary : colors.textSecondary },
]}
>
{counts[filter]}
</ThemedText>
</View>
</TouchableOpacity>
);
})}
</View>
</View>
{emptyState ? (
<View style={[styles.emptyState, { backgroundColor: colors.surface }]}>
<Image
source={require('@/assets/images/task/ImageEmpty.png')}
style={styles.emptyIllustration}
contentFit="cover"
/>
<ThemedText style={styles.emptyTitle}>{t('medications.emptyState.title')}</ThemedText>
<ThemedText style={[styles.emptySubtitle, { color: colors.textMuted }]}>
{t('medications.emptyState.subtitle')}
</ThemedText>
</View>
) : (
<View style={styles.cardsWrapper}>
{/* 渲染未服用的药物 */}
{activeMedications.map((item: any) => (
<MedicationCard
key={item.id}
medication={item}
colors={colors}
selectedDate={selectedDate}
onOpenDetails={() => handleOpenMedicationDetails(item.medicationId)}
onCelebrate={handleMedicationTakenCelebration}
/>
))}
{/* 渲染已完成(服用/跳过)的药物堆叠 */}
{completedMedications.length > 0 && (
<TakenMedicationsStack
medications={completedMedications}
colors={colors}
selectedDate={selectedDate}
onOpenDetails={(item) => handleOpenMedicationDetails(item.medicationId)}
onCelebrate={handleMedicationTakenCelebration}
/>
)}
</View>
)}
</ScrollView>
<MedicationAddOptionsSheet
visible={addSheetVisible}
onClose={() => setAddSheetVisible(false)}
onManualAdd={handleManualAdd}
onAiRecognize={handleAiRecognize}
/>
{/* 医疗免责声明弹窗 */}
<MedicalDisclaimerSheet
visible={disclaimerVisible}
onClose={handleDisclaimerClose}
onConfirm={handleDisclaimerConfirm}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
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,
},
scrollContent: {
paddingHorizontal: 20,
gap: 24,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
headerAddButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackAddButton: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
avatar: {
width: 60,
height: 60,
borderRadius: 30,
},
greeting: {
fontSize: 24,
fontWeight: '600',
},
welcome: {
marginTop: 6,
fontSize: 14,
},
sectionSpacing: {
gap: 16,
},
dateSelectorContainer: {
paddingRight: 0,
},
sectionTitle: {
fontSize: 16,
fontWeight: '500',
},
sectionHeader: {
fontSize: 20,
fontWeight: '600',
},
segmentedControl: {
flexDirection: 'row',
borderRadius: 18,
padding: 6,
gap: 6,
},
segment: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
borderRadius: 14,
paddingVertical: 10,
},
segmentLabel: {
fontSize: 14,
fontWeight: '600',
},
segmentBadge: {
minWidth: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 6,
},
segmentBadgeText: {
fontSize: 12,
fontWeight: '600',
},
emptyState: {
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 32,
borderRadius: 24,
gap: 16,
},
emptyIllustration: {
width: 160,
height: 160,
resizeMode: 'contain',
},
emptyTitle: {
textAlign: 'center',
fontSize: 18,
fontWeight: '600',
},
emptySubtitle: {
textAlign: 'center',
fontSize: 14,
lineHeight: 20,
},
primaryButton: {
marginTop: 8,
paddingVertical: 14,
paddingHorizontal: 32,
borderRadius: 22,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
primaryButtonText: {
fontSize: 16,
fontWeight: '600',
},
cardsWrapper: {
gap: 16,
},
loadingContainer: {
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 48,
borderRadius: 24,
gap: 16,
},
loadingText: {
fontSize: 14,
},
});