Files
digital-pilates/app/(tabs)/medications.tsx
richarjiang f4ce3d9edf feat(medications): 重构药品通知系统并添加独立设置页面
- 创建药品通知服务模块,统一管理药品提醒通知的调度和取消
- 新增独立的通知设置页面,支持总开关和药品提醒开关分离控制
- 重构药品详情页面,移除频率编辑功能到独立页面
- 优化药品添加流程,支持拍照和相册选择图片
- 改进通知权限检查和错误处理机制
- 更新用户偏好设置,添加药品提醒开关配置
2025-11-11 16:43:27 +08:00

455 lines
13 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 { 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,
},
});