- 移除药品添加选项底部抽屉,直接跳转至AI识别相机 - 优化AI相机拍摄完成后的按钮交互,展开为"拍照"和"完成"两个按钮 - 添加相机引导提示本地存储,避免重复显示 - 修复相机页面布局跳动问题,固定相机高度 - 为医疗免责声明组件添加触觉反馈错误处理 - 实现活动热力图的国际化支持,包括月份标签和统计文本
598 lines
19 KiB
TypeScript
598 lines
19 KiB
TypeScript
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
|
||
import { DateSelector } from '@/components/DateSelector';
|
||
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 [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);
|
||
|
||
// 直接跳转到 AI 相机页面
|
||
const handleAddMedication = useCallback(async () => {
|
||
// 先检查登录状态
|
||
const isLoggedIn = await ensureLoggedIn();
|
||
if (!isLoggedIn) return;
|
||
|
||
// 检查 VIP 权限
|
||
const access = checkServiceAccess();
|
||
if (!access.canUseService) {
|
||
openMembershipModal();
|
||
return;
|
||
}
|
||
|
||
// 直接跳转到 AI 相机页面
|
||
router.push('/medications/ai-camera');
|
||
}, [checkServiceAccess, ensureLoggedIn, openMembershipModal]);
|
||
|
||
const handleManualAdd = useCallback(() => {
|
||
const hasRead = getItemSync(MEDICAL_DISCLAIMER_READ_KEY);
|
||
setPendingAction('manual');
|
||
|
||
if (hasRead === 'true') {
|
||
setPendingAction(null);
|
||
router.push('/medications/add-medication');
|
||
} else {
|
||
setDisclaimerVisible(true);
|
||
}
|
||
}, []);
|
||
|
||
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={handleAddMedication}
|
||
>
|
||
{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>
|
||
|
||
{/* 医疗免责声明弹窗 */}
|
||
<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,
|
||
},
|
||
});
|