Files
digital-pilates/app/(tabs)/medications.tsx

546 lines
17 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 { MedicationCard } from '@/components/medication/MedicationCard';
import { ThemedText } from '@/components/ThemedText';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { medicationNotificationService } from '@/services/medicationNotifications';
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 [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);
// 从 Redux 获取数据
const selectedKey = selectedDate.format('YYYY-MM-DD');
const medicationsForDay = useAppSelector((state) => selectMedicationDisplayItemsByDate(selectedKey)(state));
const handleOpenAddMedication = useCallback(() => {
// 检查是否已经读过免责声明
const hasRead = getItemSync(MEDICAL_DISCLAIMER_READ_KEY);
if (hasRead === 'true') {
// 已读过,直接跳转
router.push('/medications/add-medication');
} else {
// 未读过,显示医疗免责声明弹窗
setDisclaimerVisible(true);
}
}, []);
const handleDisclaimerConfirm = useCallback(() => {
// 用户同意免责声明后,记录已读状态,关闭弹窗并跳转到添加页面
setItemSync(MEDICAL_DISCLAIMER_READ_KEY, 'true');
setDisclaimerVisible(false);
router.push('/medications/add-medication');
}, []);
const handleDisclaimerClose = useCallback(() => {
// 用户不接受免责声明,只关闭弹窗,不跳转,不记录已读状态
setDisclaimerVisible(false);
}, []);
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 Promise.all([
dispatch(fetchMedicationRecords({ date: selectedKey })),
medicationNotificationService.rescheduleAllMedicationNotifications(medications),
]);
// 同步数据到小组件(仅同步今天的)
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 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={handleOpenAddMedication}
>
{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}>
{filteredMedications.map((item: any) => (
<MedicationCard
key={item.id}
medication={item}
colors={colors}
selectedDate={selectedDate}
onOpenDetails={() => handleOpenMedicationDetails(item.medicationId)}
onCelebrate={handleMedicationTakenCelebration}
/>
))}
</View>
)}
</ScrollView>
{/* 医疗免责声明弹窗 */}
<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,
},
});