Files
digital-pilates/app/(tabs)/medications.tsx
richarjiang 6f2b7eb45e feat(medications): 简化药品添加流程并优化AI相机交互体验
- 移除药品添加选项底部抽屉,直接跳转至AI识别相机
- 优化AI相机拍摄完成后的按钮交互,展开为"拍照"和"完成"两个按钮
- 添加相机引导提示本地存储,避免重复显示
- 修复相机页面布局跳动问题,固定相机高度
- 为医疗免责声明组件添加触觉反馈错误处理
- 实现活动热力图的国际化支持,包括月份标签和统计文本
2025-11-25 14:09:24 +08:00

598 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 { 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,
},
});