- 创建medicineExtension小组件,支持iOS桌面显示用药计划 - 实现App Group数据共享机制,支持主应用与小组件数据同步 - 添加AppGroupUserDefaultsManager原生模块,提供跨应用数据访问能力 - 添加WidgetManager和WidgetCenterHelper,实现小组件刷新控制 - 在medications页面和Redux store中集成小组件数据同步逻辑 - 支持实时同步今日用药状态(待服用/已服用/已错过)到小组件 - 配置App Group entitlements (group.com.anonymous.digitalpilates) - 更新Xcode项目配置,添加WidgetKit和SwiftUI框架支持
500 lines
15 KiB
TypeScript
500 lines
15 KiB
TypeScript
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 { 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 { 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');
|
|
|
|
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);
|
|
|
|
// 从 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 },
|
|
});
|
|
}, []);
|
|
|
|
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;
|
|
}
|
|
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')
|
|
? 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>
|
|
</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,
|
|
},
|
|
});
|