- 创建药品通知服务模块,统一管理药品提醒通知的调度和取消 - 新增独立的通知设置页面,支持总开关和药品提醒开关分离控制 - 重构药品详情页面,移除频率编辑功能到独立页面 - 优化药品添加流程,支持拍照和相册选择图片 - 改进通知权限检查和错误处理机制 - 更新用户偏好设置,添加药品提醒开关配置
455 lines
13 KiB
TypeScript
455 lines
13 KiB
TypeScript
import { DateSelector } from '@/components/DateSelector';
|
||
import { MedicationCard } from '@/components/medication/MedicationCard';
|
||
import { ThemedText } from '@/components/ThemedText';
|
||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||
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 { 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, useState } from 'react';
|
||
import {
|
||
ScrollView,
|
||
StyleSheet,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
dayjs.locale('zh-cn');
|
||
|
||
type MedicationFilter = 'all' | 'taken' | 'missed';
|
||
|
||
type ThemeColors = (typeof Colors)[keyof typeof Colors];
|
||
|
||
export default function MedicationsScreen() {
|
||
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');
|
||
|
||
// 从 Redux 获取数据
|
||
const selectedKey = selectedDate.format('YYYY-MM-DD');
|
||
const medicationsForDay = useAppSelector((state) => selectMedicationDisplayItemsByDate(selectedKey)(state));
|
||
|
||
const handleOpenAddMedication = useCallback(() => {
|
||
router.push('/medications/add-medication');
|
||
}, []);
|
||
|
||
const handleOpenMedicationManagement = useCallback(() => {
|
||
router.push('/medications/manage-medications');
|
||
}, []);
|
||
|
||
const handleOpenMedicationDetails = useCallback((medicationId: string) => {
|
||
router.push({
|
||
pathname: '/medications/[medicationId]',
|
||
params: { medicationId },
|
||
});
|
||
}, []);
|
||
|
||
// 加载药物和记录数据
|
||
useEffect(() => {
|
||
dispatch(fetchMedications());
|
||
dispatch(fetchMedicationRecords({ date: selectedKey }));
|
||
}, [dispatch, selectedKey]);
|
||
|
||
// 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据
|
||
useFocusEffect(
|
||
useCallback(() => {
|
||
// 重新安排药品通知并刷新数据
|
||
const refreshDataAndRescheduleNotifications = async () => {
|
||
try {
|
||
// 只获取一次药物数据,然后复用结果
|
||
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
|
||
|
||
// 并行执行获取药物记录和安排通知
|
||
await Promise.all([
|
||
dispatch(fetchMedicationRecords({ date: selectedKey })),
|
||
medicationNotificationService.rescheduleAllMedicationNotifications(medications),
|
||
]);
|
||
} 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;
|
||
}
|
||
return medicationsWithImages.filter((item: any) => item.status === activeFilter);
|
||
}, [activeFilter, medicationsWithImages]);
|
||
|
||
const counts = useMemo(() => {
|
||
const taken = medicationsWithImages.filter((item: any) => item.status === 'taken').length;
|
||
const missed = medicationsWithImages.filter((item: any) => item.status === 'missed').length;
|
||
return {
|
||
all: medicationsWithImages.length,
|
||
taken,
|
||
missed,
|
||
};
|
||
}, [medicationsWithImages]);
|
||
|
||
const displayName = userProfile.name?.trim() || DEFAULT_MEMBER_NAME;
|
||
const headerDateLabel = selectedDate.isSame(dayjs(), 'day')
|
||
? `今天,${selectedDate.format('M月D日')}`
|
||
: selectedDate.format('M月D日 dddd');
|
||
|
||
const emptyState = filteredMedications.length === 0;
|
||
|
||
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} />
|
||
|
||
<ScrollView
|
||
contentContainerStyle={[
|
||
styles.scrollContent,
|
||
{ paddingTop: insets.top + 24, paddingBottom: insets.bottom + 36 },
|
||
]}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
<View style={styles.header}>
|
||
<View>
|
||
<ThemedText style={styles.greeting}>你好,{displayName}</ThemedText>
|
||
<ThemedText style={[styles.welcome, { color: colors.textMuted }]}>
|
||
欢迎来到用药助手!
|
||
</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}>今日用药</ThemedText>
|
||
<View style={[styles.segmentedControl, { backgroundColor: colors.surface }]}>
|
||
{(['all', 'taken', 'missed'] as MedicationFilter[]).map((filter) => {
|
||
const isActive = activeFilter === filter;
|
||
const labelMap: Record<MedicationFilter, string> = {
|
||
all: '全部',
|
||
taken: '已服用',
|
||
missed: '未服用',
|
||
};
|
||
return (
|
||
<TouchableOpacity
|
||
key={filter}
|
||
onPress={() => setActiveFilter(filter)}
|
||
style={[
|
||
styles.segment,
|
||
isActive && { backgroundColor: colors.primary },
|
||
]}
|
||
>
|
||
<ThemedText
|
||
style={[
|
||
styles.segmentLabel,
|
||
{ color: isActive ? colors.onPrimary : colors.textSecondary },
|
||
]}
|
||
>
|
||
{labelMap[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}>今日暂无用药安排</ThemedText>
|
||
<ThemedText style={[styles.emptySubtitle, { color: colors.textMuted }]}>
|
||
还未添加任何用药计划,快来补充吧。
|
||
</ThemedText>
|
||
</View>
|
||
) : (
|
||
<View style={styles.cardsWrapper}>
|
||
{filteredMedications.map((item: any) => (
|
||
<MedicationCard
|
||
key={item.id}
|
||
medication={item}
|
||
colors={colors}
|
||
selectedDate={selectedDate}
|
||
onOpenDetails={() => handleOpenMedicationDetails(item.medicationId)}
|
||
/>
|
||
))}
|
||
</View>
|
||
)}
|
||
</ScrollView>
|
||
</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,
|
||
},
|
||
});
|