feat(medications): 添加用药管理功能
- 新增用药标签页,包含完整的用药记录界面 - 实现用药卡片组件,支持状态显示(已服用/未服用/已错过) - 增强日期选择器,添加"回到今天"快捷功能 - 添加用药相关的图标支持(pills.fill, plus) - 集成用药路由配置,支持标签页导航 该功能为用户提供完整的用药管理体验,包括用药记录、状态跟踪和日期筛选等核心功能。
This commit is contained in:
@@ -21,6 +21,7 @@ type TabConfig = {
|
|||||||
|
|
||||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||||
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
||||||
|
medications: { icon: 'pills.fill', title: '用药' },
|
||||||
fasting: { icon: 'timer', title: '断食' },
|
fasting: { icon: 'timer', title: '断食' },
|
||||||
challenges: { icon: 'trophy.fill', title: '挑战' },
|
challenges: { icon: 'trophy.fill', title: '挑战' },
|
||||||
personal: { icon: 'person.fill', title: '个人' },
|
personal: { icon: 'person.fill', title: '个人' },
|
||||||
@@ -36,6 +37,7 @@ export default function TabLayout() {
|
|||||||
const isTabSelected = (routeName: string): boolean => {
|
const isTabSelected = (routeName: string): boolean => {
|
||||||
const routeMap: Record<string, string> = {
|
const routeMap: Record<string, string> = {
|
||||||
statistics: ROUTES.TAB_STATISTICS,
|
statistics: ROUTES.TAB_STATISTICS,
|
||||||
|
medications: ROUTES.TAB_MEDICATIONS,
|
||||||
fasting: ROUTES.TAB_FASTING,
|
fasting: ROUTES.TAB_FASTING,
|
||||||
challenges: ROUTES.TAB_CHALLENGES,
|
challenges: ROUTES.TAB_CHALLENGES,
|
||||||
personal: ROUTES.TAB_PERSONAL,
|
personal: ROUTES.TAB_PERSONAL,
|
||||||
@@ -176,6 +178,10 @@ export default function TabLayout() {
|
|||||||
<Label>健康</Label>
|
<Label>健康</Label>
|
||||||
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
|
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="medications">
|
||||||
|
<Icon sf="pills.fill" drawable="custom_android_drawable" />
|
||||||
|
<Label>用药</Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger name="fasting">
|
<NativeTabs.Trigger name="fasting">
|
||||||
<Icon sf="timer" drawable="custom_android_drawable" />
|
<Icon sf="timer" drawable="custom_android_drawable" />
|
||||||
<Label>断食</Label>
|
<Label>断食</Label>
|
||||||
@@ -198,6 +204,7 @@ export default function TabLayout() {
|
|||||||
>
|
>
|
||||||
|
|
||||||
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
||||||
|
<Tabs.Screen name="medications" options={{ title: '用药' }} />
|
||||||
<Tabs.Screen name="fasting" options={{ title: '断食' }} />
|
<Tabs.Screen name="fasting" options={{ title: '断食' }} />
|
||||||
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
|
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
|
||||||
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
||||||
|
|||||||
423
app/(tabs)/medications.tsx
Normal file
423
app/(tabs)/medications.tsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
|
import { MedicationCard, type Medication, type MedicationStatus } from '@/components/medication/MedicationCard';
|
||||||
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
|
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import React, { 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];
|
||||||
|
|
||||||
|
const MEDICATION_IMAGES = {
|
||||||
|
bottle: require('@/assets/images/icons/icon-healthy-diet.png'),
|
||||||
|
drops: require('@/assets/images/icons/icon-remind.png'),
|
||||||
|
vitamins: require('@/assets/images/icons/icon-blood-oxygen.png'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MedicationsScreen() {
|
||||||
|
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 scheduledMedications = useMemo(() => {
|
||||||
|
const today = dayjs();
|
||||||
|
const todayKey = today.format('YYYY-MM-DD');
|
||||||
|
const yesterdayKey = today.subtract(1, 'day').format('YYYY-MM-DD');
|
||||||
|
const twoDaysAgoKey = today.subtract(2, 'day').format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
return {
|
||||||
|
[todayKey]: [
|
||||||
|
{
|
||||||
|
id: 'med-1',
|
||||||
|
name: 'Metformin',
|
||||||
|
dosage: '1 粒胶囊',
|
||||||
|
scheduledTime: '09:00',
|
||||||
|
frequency: '每日',
|
||||||
|
status: 'upcoming' as MedicationStatus,
|
||||||
|
image: MEDICATION_IMAGES.bottle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'med-2',
|
||||||
|
name: 'Captopril',
|
||||||
|
dosage: '2 粒胶囊',
|
||||||
|
scheduledTime: '20:00',
|
||||||
|
frequency: '每日',
|
||||||
|
status: 'upcoming' as MedicationStatus,
|
||||||
|
image: MEDICATION_IMAGES.vitamins,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'med-3',
|
||||||
|
name: 'B 12',
|
||||||
|
dosage: '1 次注射',
|
||||||
|
scheduledTime: '22:00',
|
||||||
|
frequency: '每日',
|
||||||
|
status: 'taken' as MedicationStatus,
|
||||||
|
image: MEDICATION_IMAGES.vitamins,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'med-4',
|
||||||
|
name: 'I-DROP MGD',
|
||||||
|
dosage: '2 滴',
|
||||||
|
scheduledTime: '22:00',
|
||||||
|
frequency: '每日',
|
||||||
|
status: 'missed' as MedicationStatus,
|
||||||
|
image: MEDICATION_IMAGES.drops,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'med-5',
|
||||||
|
name: 'Niacin',
|
||||||
|
dosage: '0.5 片',
|
||||||
|
scheduledTime: '22:00',
|
||||||
|
frequency: '每日',
|
||||||
|
status: 'missed' as MedicationStatus,
|
||||||
|
image: MEDICATION_IMAGES.bottle,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[yesterdayKey]: [
|
||||||
|
{
|
||||||
|
id: 'med-6',
|
||||||
|
name: 'B 12',
|
||||||
|
dosage: '1 次注射',
|
||||||
|
scheduledTime: '22:00',
|
||||||
|
frequency: '每日',
|
||||||
|
status: 'taken' as MedicationStatus,
|
||||||
|
image: MEDICATION_IMAGES.vitamins,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[twoDaysAgoKey]: [
|
||||||
|
{
|
||||||
|
id: 'med-7',
|
||||||
|
name: 'I-DROP MGD',
|
||||||
|
dosage: '2 滴',
|
||||||
|
scheduledTime: '22:00',
|
||||||
|
frequency: '每日',
|
||||||
|
status: 'missed' as MedicationStatus,
|
||||||
|
image: MEDICATION_IMAGES.drops,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'med-8',
|
||||||
|
name: 'Niacin',
|
||||||
|
dosage: '0.5 片',
|
||||||
|
scheduledTime: '22:00',
|
||||||
|
frequency: '每日',
|
||||||
|
status: 'missed' as MedicationStatus,
|
||||||
|
image: MEDICATION_IMAGES.bottle,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Record<string, Medication[]>;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveFilter('all');
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
const selectedKey = selectedDate.format('YYYY-MM-DD');
|
||||||
|
const medicationsForDay = scheduledMedications[selectedKey] ?? [];
|
||||||
|
|
||||||
|
const filteredMedications = useMemo(() => {
|
||||||
|
if (activeFilter === 'all') {
|
||||||
|
return medicationsForDay;
|
||||||
|
}
|
||||||
|
return medicationsForDay.filter((item) => item.status === activeFilter);
|
||||||
|
}, [activeFilter, medicationsForDay]);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const taken = medicationsForDay.filter((item) => item.status === 'taken').length;
|
||||||
|
const missed = medicationsForDay.filter((item) => item.status === 'missed').length;
|
||||||
|
return {
|
||||||
|
all: medicationsForDay.length,
|
||||||
|
taken,
|
||||||
|
missed,
|
||||||
|
};
|
||||||
|
}, [medicationsForDay]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.primaryButton, { backgroundColor: colors.primary }]}
|
||||||
|
>
|
||||||
|
<IconSymbol name="plus" size={18} color={colors.onPrimary} />
|
||||||
|
<ThemedText style={[styles.primaryButtonText, { color: colors.onPrimary }]}>
|
||||||
|
添加用药
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.cardsWrapper}>
|
||||||
|
{filteredMedications.map((item) => (
|
||||||
|
<MedicationCard
|
||||||
|
key={item.id}
|
||||||
|
medication={item}
|
||||||
|
colors={colors}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -58,6 +58,17 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
const days = getMonthDaysZh(currentMonth);
|
const days = getMonthDaysZh(currentMonth);
|
||||||
const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth);
|
const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth);
|
||||||
|
|
||||||
|
// 判断当前选中的日期是否是今天
|
||||||
|
const isSelectedDateToday = () => {
|
||||||
|
const today = dayjs();
|
||||||
|
const selectedDate = days[selectedIndex]?.date;
|
||||||
|
|
||||||
|
if (!selectedDate) return false;
|
||||||
|
|
||||||
|
// 检查是否是同一天且在同一个月
|
||||||
|
return selectedDate.isSame(today, 'day') && currentMonth.isSame(today, 'month');
|
||||||
|
};
|
||||||
|
|
||||||
// 滚动相关
|
// 滚动相关
|
||||||
const daysScrollRef = useRef<ScrollView | null>(null);
|
const daysScrollRef = useRef<ScrollView | null>(null);
|
||||||
const [scrollWidth, setScrollWidth] = useState(0);
|
const [scrollWidth, setScrollWidth] = useState(0);
|
||||||
@@ -191,20 +202,47 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGoToday = () => {
|
||||||
|
const today = dayjs();
|
||||||
|
setCurrentMonth(today);
|
||||||
|
const todayDays = getMonthDaysZh(today);
|
||||||
|
const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date());
|
||||||
|
|
||||||
|
if (newSelectedIndex !== -1) {
|
||||||
|
if (externalSelectedIndex === undefined) {
|
||||||
|
setInternalSelectedIndex(newSelectedIndex);
|
||||||
|
}
|
||||||
|
const todayDate = today.toDate();
|
||||||
|
setPickerDate(todayDate);
|
||||||
|
onDateSelect?.(newSelectedIndex, todayDate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, containerStyle]}>
|
<View style={[styles.container, containerStyle]}>
|
||||||
{showMonthTitle && (
|
{showMonthTitle && (
|
||||||
<View style={styles.monthTitleContainer}>
|
<View style={styles.monthTitleContainer}>
|
||||||
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
||||||
{showCalendarIcon && (
|
<View style={styles.monthActions}>
|
||||||
<TouchableOpacity
|
{!isSelectedDateToday() && (
|
||||||
onPress={openDatePicker}
|
<TouchableOpacity
|
||||||
style={styles.calendarIconButton}
|
onPress={handleGoToday}
|
||||||
activeOpacity={0.7}
|
style={styles.todayButton}
|
||||||
>
|
activeOpacity={0.8}
|
||||||
<Ionicons name="calendar-outline" size={14} color="#666666" />
|
>
|
||||||
</TouchableOpacity>
|
<Text style={styles.todayButtonText}>回到今天</Text>
|
||||||
)}
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{showCalendarIcon && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={openDatePicker}
|
||||||
|
style={styles.calendarIconButton}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="calendar-outline" size={14} color="#666666" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -305,9 +343,13 @@ const styles = StyleSheet.create({
|
|||||||
monthTitleContainer: {
|
monthTitleContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'space-between',
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
|
monthActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
monthTitle: {
|
monthTitle: {
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
@@ -318,6 +360,18 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
marginLeft: 4
|
marginLeft: 4
|
||||||
},
|
},
|
||||||
|
todayButton: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#EEF2FF',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
todayButtonText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#4C1D95',
|
||||||
|
},
|
||||||
daysContainer: {
|
daysContainer: {
|
||||||
paddingBottom: 8,
|
paddingBottom: 8,
|
||||||
},
|
},
|
||||||
|
|||||||
301
components/medication/MedicationCard.tsx
Normal file
301
components/medication/MedicationCard.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
export type MedicationStatus = 'upcoming' | 'taken' | 'missed';
|
||||||
|
|
||||||
|
export type Medication = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
dosage: string;
|
||||||
|
scheduledTime: string;
|
||||||
|
frequency: string;
|
||||||
|
status: MedicationStatus;
|
||||||
|
image: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MedicationCardProps = {
|
||||||
|
medication: Medication;
|
||||||
|
colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors];
|
||||||
|
selectedDate: Dayjs;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MedicationCard({ medication, colors, selectedDate }: MedicationCardProps) {
|
||||||
|
const scheduledDate = dayjs(`${selectedDate.format('YYYY-MM-DD')} ${medication.scheduledTime}`);
|
||||||
|
const timeDiffMinutes = scheduledDate.diff(dayjs(), 'minute');
|
||||||
|
|
||||||
|
const renderStatusBadge = () => {
|
||||||
|
if (medication.status === 'missed') {
|
||||||
|
return (
|
||||||
|
<View style={[styles.statusChip, styles.statusChipMissed]}>
|
||||||
|
<ThemedText style={styles.statusChipText}>已错过</ThemedText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (medication.status === 'upcoming') {
|
||||||
|
if (timeDiffMinutes <= 0) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.statusChip, styles.statusChipUpcoming]}>
|
||||||
|
<Ionicons name="time-outline" size={14} color="#fff" />
|
||||||
|
<ThemedText style={styles.statusChipText}>到服药时间</ThemedText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(timeDiffMinutes / 60);
|
||||||
|
const minutes = timeDiffMinutes % 60;
|
||||||
|
const formatted =
|
||||||
|
hours > 0 ? `${hours}小时${minutes > 0 ? `${minutes}分钟` : ''}` : `${minutes}分钟`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.statusChip, styles.statusChipUpcoming]}>
|
||||||
|
<Ionicons name="time-outline" size={14} color="#fff" />
|
||||||
|
<ThemedText style={styles.statusChipText}>剩余 {formatted}</ThemedText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAction = () => {
|
||||||
|
if (medication.status === 'taken') {
|
||||||
|
return (
|
||||||
|
<View style={[styles.actionButton, styles.actionButtonTaken]}>
|
||||||
|
<Ionicons name="checkmark-circle" size={18} color="#fff" />
|
||||||
|
<ThemedText style={styles.actionButtonText}>已服用</ThemedText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (medication.status === 'missed') {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={1}
|
||||||
|
disabled={true}
|
||||||
|
onPress={() => {
|
||||||
|
// 已错过的药物不能服用
|
||||||
|
console.log('已错过的药物不能服用');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLiquidGlassAvailable() ? (
|
||||||
|
<GlassView
|
||||||
|
style={[styles.actionButton, styles.actionButtonMissed]}
|
||||||
|
glassEffectStyle="clear"
|
||||||
|
tintColor="rgba(156, 163, 175, 0.3)"
|
||||||
|
isInteractive={false}
|
||||||
|
>
|
||||||
|
<ThemedText style={styles.actionButtonTextMissed}>已错过</ThemedText>
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.actionButton, styles.actionButtonMissed, styles.fallbackActionButtonMissed]}>
|
||||||
|
<ThemedText style={styles.actionButtonTextMissed}>已错过</ThemedText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => {
|
||||||
|
// TODO: 实现服药功能
|
||||||
|
console.log('服药功能待实现');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLiquidGlassAvailable() ? (
|
||||||
|
<GlassView
|
||||||
|
style={[styles.actionButton, styles.actionButtonUpcoming]}
|
||||||
|
glassEffectStyle="clear"
|
||||||
|
tintColor="rgba(19, 99, 255, 0.3)"
|
||||||
|
isInteractive={true}
|
||||||
|
>
|
||||||
|
<ThemedText style={styles.actionButtonText}>立即服用</ThemedText>
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.actionButton, styles.actionButtonUpcoming, styles.fallbackActionButton]}>
|
||||||
|
<ThemedText style={styles.actionButtonText}>立即服用</ThemedText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusChip = renderStatusBadge();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.card, { shadowColor: colors.text }]}>
|
||||||
|
<View style={[styles.cardSurface, { backgroundColor: colors.surface }]}>
|
||||||
|
{statusChip ? <View style={styles.statusChipWrapper}>{statusChip}</View> : null}
|
||||||
|
<View style={styles.cardBody}>
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
<View style={styles.thumbnailWrapper}>
|
||||||
|
<View style={styles.thumbnailSurface}>
|
||||||
|
<Image source={medication.image} style={styles.thumbnailImage} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoSection}>
|
||||||
|
<ThemedText style={[styles.cardTitle, { color: colors.text }]}>
|
||||||
|
{medication.name}
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText style={[styles.cardDosage, { color: colors.textSecondary }]}>
|
||||||
|
{medication.dosage}
|
||||||
|
</ThemedText>
|
||||||
|
<View style={styles.scheduleRow}>
|
||||||
|
<Ionicons
|
||||||
|
name="time-outline"
|
||||||
|
size={14}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
style={styles.scheduleIcon}
|
||||||
|
/>
|
||||||
|
<ThemedText style={[styles.cardSchedule, { color: colors.textSecondary }]}>
|
||||||
|
{medication.scheduledTime} | {medication.frequency}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
<View style={styles.actionContainer}>{renderAction()}</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
borderRadius: 26,
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowOffset: { width: 0, height: 12 },
|
||||||
|
shadowRadius: 24,
|
||||||
|
elevation: 2,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
cardSurface: {
|
||||||
|
borderRadius: 26,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
cardBody: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 20,
|
||||||
|
paddingTop: 28,
|
||||||
|
},
|
||||||
|
cardContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 20,
|
||||||
|
},
|
||||||
|
thumbnailWrapper: {
|
||||||
|
width: 126,
|
||||||
|
height: 110,
|
||||||
|
},
|
||||||
|
thumbnailSurface: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: '#F1F4FF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
thumbnailImage: {
|
||||||
|
width: '80%',
|
||||||
|
height: '80%',
|
||||||
|
resizeMode: 'contain',
|
||||||
|
},
|
||||||
|
infoSection: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
cardDosage: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
scheduleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
cardSchedule: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
scheduleIcon: {
|
||||||
|
marginTop: -1,
|
||||||
|
},
|
||||||
|
actionContainer: {
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
actionButtonUpcoming: {
|
||||||
|
backgroundColor: '#1363FF',
|
||||||
|
},
|
||||||
|
actionButtonTaken: {
|
||||||
|
backgroundColor: '#1FBF4B',
|
||||||
|
},
|
||||||
|
actionButtonMissed: {
|
||||||
|
backgroundColor: '#9CA3AF',
|
||||||
|
},
|
||||||
|
fallbackActionButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(19, 99, 255, 0.3)',
|
||||||
|
backgroundColor: 'rgba(19, 99, 255, 0.9)',
|
||||||
|
},
|
||||||
|
fallbackActionButtonMissed: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(156, 163, 175, 0.3)',
|
||||||
|
backgroundColor: 'rgba(156, 163, 175, 0.9)',
|
||||||
|
},
|
||||||
|
actionButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
actionButtonTextMissed: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
statusChipWrapper: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
},
|
||||||
|
statusChip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
height: 28,
|
||||||
|
borderBottomLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
backgroundColor: '#1363FF',
|
||||||
|
},
|
||||||
|
statusChipUpcoming: {
|
||||||
|
backgroundColor: '#1363FF',
|
||||||
|
},
|
||||||
|
statusChipMissed: {
|
||||||
|
backgroundColor: '#FF3B30',
|
||||||
|
},
|
||||||
|
statusChipText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -23,6 +23,8 @@ const MAPPING = {
|
|||||||
'trophy.fill': 'emoji-events',
|
'trophy.fill': 'emoji-events',
|
||||||
'timer': 'timer',
|
'timer': 'timer',
|
||||||
'person.fill': 'person',
|
'person.fill': 'person',
|
||||||
|
'plus': 'add',
|
||||||
|
'pills.fill': 'medication',
|
||||||
'person.3.fill': 'people',
|
'person.3.fill': 'people',
|
||||||
'message.fill': 'message',
|
'message.fill': 'message',
|
||||||
'info.circle': 'info',
|
'info.circle': 'info',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const ROUTES = {
|
|||||||
TAB_EXPLORE: '/explore',
|
TAB_EXPLORE: '/explore',
|
||||||
TAB_COACH: '/coach',
|
TAB_COACH: '/coach',
|
||||||
TAB_STATISTICS: '/statistics',
|
TAB_STATISTICS: '/statistics',
|
||||||
|
TAB_MEDICATIONS: '/medications',
|
||||||
TAB_CHALLENGES: '/challenges',
|
TAB_CHALLENGES: '/challenges',
|
||||||
TAB_PERSONAL: '/personal',
|
TAB_PERSONAL: '/personal',
|
||||||
TAB_FASTING: '/fasting',
|
TAB_FASTING: '/fasting',
|
||||||
|
|||||||
Reference in New Issue
Block a user