Files
digital-pilates/app/(tabs)/fasting.tsx
richarjiang 6ad77bc0e2 feat(medical): 添加医疗免责声明和参考文献功能
- 在用药模块首次添加时显示医疗免责声明弹窗
- 新增断食参考文献页面,展示权威医学机构来源
- 在个人中心添加WHO医学来源入口
- 使用本地存储记录用户已读免责声明状态
- 支持Liquid Glass毛玻璃效果和降级方案
- 新增中英文国际化翻译支持
2025-11-14 09:14:12 +08:00

833 lines
25 KiB
TypeScript
Raw Permalink 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 { FastingOverviewCard } from '@/components/fasting/FastingOverviewCard';
import { FastingPlanList } from '@/components/fasting/FastingPlanList';
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal';
import { NotificationErrorAlert } from '@/components/ui/NotificationErrorAlert';
import { FASTING_PLANS, FastingPlan, getPlanById, getRecommendedStart } from '@/constants/Fasting';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useCountdown } from '@/hooks/useCountdown';
import { useFastingCycleNotifications } from '@/hooks/useFastingCycleNotifications';
import { useFastingNotifications } from '@/hooks/useFastingNotifications';
import {
clearActiveSchedule,
completeCurrentCycleSession,
hydrateFastingCycle,
pauseFastingCycle,
rescheduleActivePlan,
resumeFastingCycle,
scheduleFastingPlan,
selectActiveCyclePlan,
// 周期性断食相关的 selectors
selectActiveFastingCycle,
selectActiveFastingPlan,
selectActiveFastingSchedule,
selectCurrentCyclePlan,
selectCurrentCycleSession,
selectCurrentFastingPlan,
selectCurrentFastingTimes,
selectCycleHistory,
selectIsInCycleMode,
startFastingCycle,
stopFastingCycle,
updateFastingCycleTime
} from '@/store/fastingSlice';
import {
buildDisplayWindow,
getFastingPhase,
getPhaseLabel,
// 周期性断食相关的工具函数
loadActiveFastingCycle,
loadCurrentCycleSession,
loadCycleHistory,
loadPreferredPlanId,
saveActiveFastingCycle,
saveCurrentCycleSession,
saveCycleHistory,
savePreferredPlanId
} from '@/utils/fasting';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function FastingTabScreen() {
const router = useRouter();
const dispatch = useAppDispatch();
const insets = useSafeAreaInsets();
const scrollViewRef = React.useRef<ScrollView>(null);
// 单次断食计划的状态
const activeSchedule = useAppSelector(selectActiveFastingSchedule);
const activePlan = useAppSelector(selectActiveFastingPlan);
// 周期性断食计划的状态
const activeCycle = useAppSelector(selectActiveFastingCycle);
const currentCycleSession = useAppSelector(selectCurrentCycleSession);
const cycleHistory = useAppSelector(selectCycleHistory);
const activeCyclePlan = useAppSelector(selectActiveCyclePlan);
const currentCyclePlan = useAppSelector(selectCurrentCyclePlan);
// 统一的当前断食信息(优先显示周期性)
const currentPlan = useAppSelector(selectCurrentFastingPlan);
const currentTimes = useAppSelector(selectCurrentFastingTimes);
const isInCycleMode = useAppSelector(selectIsInCycleMode);
const defaultPlan = FASTING_PLANS.find((plan) => plan.id === '14-10') ?? FASTING_PLANS[0];
const [preferredPlanId, setPreferredPlanId] = useState<string | undefined>(currentPlan?.id ?? undefined);
// 数据持久化
useEffect(() => {
if (!currentPlan?.id) return;
setPreferredPlanId(currentPlan.id);
void savePreferredPlanId(currentPlan.id);
}, [currentPlan?.id]);
useEffect(() => {
let cancelled = false;
const hydratePreferredPlan = async () => {
try {
const savedPlanId = await loadPreferredPlanId();
if (cancelled) return;
if (currentPlan?.id) return;
if (savedPlanId && getPlanById(savedPlanId)) {
setPreferredPlanId(savedPlanId);
}
} catch (error) {
console.warn('读取断食首选计划失败', error);
}
};
hydratePreferredPlan();
return () => {
cancelled = true;
};
}, [currentPlan?.id]);
// 加载周期性断食数据
useEffect(() => {
let cancelled = false;
const hydrateCycleData = async () => {
try {
if (cancelled) return;
const [cycleData, sessionData, historyData] = await Promise.all([
loadActiveFastingCycle(),
loadCurrentCycleSession(),
loadCycleHistory(),
]);
if (cancelled) return;
dispatch(hydrateFastingCycle({
activeCycle: cycleData,
currentCycleSession: sessionData,
cycleHistory: historyData,
}));
} catch (error) {
console.warn('加载周期性断食数据失败', error);
}
};
hydrateCycleData();
return () => {
cancelled = true;
};
}, [dispatch]);
// 保存周期性断食数据,增加错误处理
useEffect(() => {
const saveCycleData = async () => {
try {
if (activeCycle) {
await saveActiveFastingCycle(activeCycle);
} else {
await saveActiveFastingCycle(null);
}
} catch (error) {
console.error('保存周期性断食计划失败', error);
// TODO: 可以在这里添加用户提示
}
};
saveCycleData();
}, [activeCycle]);
useEffect(() => {
const saveSessionData = async () => {
try {
if (currentCycleSession) {
await saveCurrentCycleSession(currentCycleSession);
} else {
await saveCurrentCycleSession(null);
}
} catch (error) {
console.error('保存断食会话失败', error);
// TODO: 可以在这里添加用户提示
}
};
saveSessionData();
}, [currentCycleSession]);
useEffect(() => {
const saveHistoryData = async () => {
try {
await saveCycleHistory(cycleHistory);
} catch (error) {
console.error('保存断食历史失败', error);
// TODO: 可以在这里添加用户提示
}
};
saveHistoryData();
}, [cycleHistory]);
// 使用单次断食通知管理 hook
const {
isReady: notificationsReady,
isLoading: notificationsLoading,
error: notificationError,
notificationIds,
lastSyncTime,
verifyAndSync,
forceSync,
clearError,
} = useFastingNotifications(activeSchedule, activePlan);
// 使用周期性断食通知管理 hook
const {
isReady: cycleNotificationsReady,
isLoading: cycleNotificationsLoading,
error: cycleNotificationError,
lastSyncTime: cycleLastSyncTime,
verifyAndSync: verifyAndSyncCycle,
forceSync: forceSyncCycle,
clearError: clearCycleError,
} = useFastingCycleNotifications(activeCycle, currentCycleSession, currentCyclePlan);
// 每次进入页面时验证通知
// 添加节流机制,避免频繁触发验证
const lastVerifyTimeRef = React.useRef<number>(0);
const lastCycleVerifyTimeRef = React.useRef<number>(0);
useFocusEffect(
useCallback(() => {
const now = Date.now();
const timeSinceLastVerify = now - lastVerifyTimeRef.current;
// 如果距离上次验证不足 30 秒,跳过本次验证
if (timeSinceLastVerify < 30000) {
return;
}
lastVerifyTimeRef.current = now;
verifyAndSync();
}, [verifyAndSync])
);
useFocusEffect(
useCallback(() => {
const now = Date.now();
const timeSinceLastVerify = now - lastCycleVerifyTimeRef.current;
// 如果距离上次验证不足 30 秒,跳过本次验证
if (timeSinceLastVerify < 30000) {
return;
}
lastCycleVerifyTimeRef.current = now;
verifyAndSyncCycle();
}, [verifyAndSyncCycle])
);
// 使用统一的当前断食时间
const scheduleStart = useMemo(() => {
if (currentTimes) {
return new Date(currentTimes.startISO);
}
return undefined;
}, [currentTimes]);
const scheduleEnd = useMemo(() => {
if (currentTimes) {
return new Date(currentTimes.endISO);
}
return undefined;
}, [currentTimes]);
const phase = getFastingPhase(scheduleStart ?? null, scheduleEnd ?? null);
const countdownTarget = phase === 'fasting' ? scheduleEnd : scheduleStart;
const { formatted: countdownValue } = useCountdown({ target: countdownTarget ?? null });
const progress = useMemo(() => {
if (!scheduleStart || !scheduleEnd) return 0;
const total = scheduleEnd.getTime() - scheduleStart.getTime();
if (total <= 0) return 0;
const now = Date.now();
if (now <= scheduleStart.getTime()) return 0;
if (now >= scheduleEnd.getTime()) return 1;
return (now - scheduleStart.getTime()) / total;
}, [scheduleStart, scheduleEnd]);
const displayWindow = buildDisplayWindow(scheduleStart ?? null, scheduleEnd ?? null);
const [showPicker, setShowPicker] = useState(false);
// 显示通知错误(如果有)
useEffect(() => {
if (notificationError) {
console.warn('断食通知错误:', notificationError);
// 可以在这里添加用户提示,比如 Toast 或 Snackbar
}
}, [notificationError]);
const recommendedDate = useMemo(() => {
const planToUse = currentPlan || defaultPlan;
return getRecommendedStart(planToUse);
}, [currentPlan, defaultPlan]);
// 调试信息(开发环境)
useEffect(() => {
if (__DEV__ && lastSyncTime) {
console.log('单次断食通知状态:', {
ready: notificationsReady,
loading: notificationsLoading,
error: notificationError,
notificationIds,
lastSyncTime,
schedule: activeSchedule?.startISO,
plan: activePlan?.id,
});
}
}, [notificationsReady, notificationsLoading, notificationError, notificationIds, lastSyncTime, activeSchedule?.startISO, activePlan?.id]);
useEffect(() => {
if (__DEV__ && cycleLastSyncTime) {
console.log('周期性断食通知状态:', {
ready: cycleNotificationsReady,
loading: cycleNotificationsLoading,
error: cycleNotificationError,
lastSyncTime: cycleLastSyncTime,
cycle: activeCycle?.planId,
session: currentCycleSession?.cycleDate,
});
}
}, [cycleNotificationsReady, cycleNotificationsLoading, cycleNotificationError, cycleLastSyncTime, activeCycle?.planId, currentCycleSession?.cycleDate]);
// 周期性断食的自动续订逻辑
// 移除1小时限制但需要用户手动确认开始下一轮周期
useEffect(() => {
if (!currentCycleSession || !activeCycle || !currentCyclePlan) return;
if (!activeCycle.enabled) return; // 如果周期已暂停,不自动完成
if (phase !== 'completed') return;
const end = dayjs(currentCycleSession.endISO);
if (!end.isValid()) return;
const now = dayjs();
if (now.isBefore(end)) return;
// 检查当前会话是否已经标记为完成
if (currentCycleSession.completed) {
if (__DEV__) {
console.log('当前会话已完成,跳过自动完成');
}
return;
}
if (__DEV__) {
console.log('自动完成当前断食周期:', {
cycleDate: currentCycleSession.cycleDate,
planId: currentCycleSession.planId,
endTime: end.format('YYYY-MM-DD HH:mm'),
timeSinceEnd: now.diff(end, 'minute') + '分钟',
});
}
// 完成当前周期并创建下一个周期
// 这会自动创建下一天的会话,不需要用户手动操作
dispatch(completeCurrentCycleSession());
}, [dispatch, currentCycleSession, activeCycle, currentCyclePlan, phase]);
// 保留原有的单次断食自动续订逻辑(向后兼容)
useEffect(() => {
if (!activeSchedule || !activePlan) return;
if (phase !== 'completed') return;
const start = dayjs(activeSchedule.startISO);
const end = dayjs(activeSchedule.endISO);
if (!start.isValid() || !end.isValid()) return;
const now = dayjs();
if (now.isBefore(end)) return;
// 检查是否在短时间内已经续订过,避免重复续订
const timeSinceEnd = now.diff(end, 'minute');
if (timeSinceEnd > 60) {
// 如果周期结束超过1小时说明用户可能不再需要自动续订
if (__DEV__) {
console.log('断食周期结束超过1小时不自动续订');
}
return;
}
// 使用每日固定时间计算下一个周期
// 保持原始的开始时间(小时和分钟),只增加日期
const originalStartHour = start.hour();
const originalStartMinute = start.minute();
// 计算下一个开始时间:明天的同一时刻
let nextStart = now.startOf('day').hour(originalStartHour).minute(originalStartMinute);
// 如果计算出的时间在当前时间之前,则使用后天的同一时刻
if (nextStart.isBefore(now)) {
nextStart = nextStart.add(1, 'day');
}
const nextEnd = nextStart.add(activePlan.fastingHours, 'hour');
if (__DEV__) {
console.log('自动续订断食周期:', {
oldStart: start.format('YYYY-MM-DD HH:mm'),
oldEnd: end.format('YYYY-MM-DD HH:mm'),
nextStart: nextStart.format('YYYY-MM-DD HH:mm'),
nextEnd: nextEnd.format('YYYY-MM-DD HH:mm'),
});
}
dispatch(rescheduleActivePlan({
start: nextStart.toISOString(),
origin: 'auto',
}));
}, [dispatch, activeSchedule, activePlan, phase]);
const handleAdjustStart = () => {
setShowPicker(true);
};
const handleConfirmStart = (date: Date) => {
// 如果没有当前计划,使用默认计划
const planToUse = currentPlan || defaultPlan;
// 如果处于周期性模式,更新周期性时间
if (isInCycleMode && activeCycle) {
const hour = date.getHours();
const minute = date.getMinutes();
dispatch(updateFastingCycleTime({ startHour: hour, startMinute: minute }));
} else if (activeSchedule) {
// 单次断食模式,重新安排
dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
} else {
// 创建新的单次断食计划
dispatch(scheduleFastingPlan({ planId: planToUse.id, start: date.toISOString(), origin: 'manual' }));
}
};
const handleSelectPlan = (plan: FastingPlan) => {
router.push(`${ROUTES.FASTING_PLAN_DETAIL}/${plan.id}`);
};
const handleViewMeal = () => {
router.push(ROUTES.FOOD_LIBRARY);
};
const handleResetPlan = () => {
// 如果没有活跃计划,不执行任何操作
if (!currentPlan) return;
if (isInCycleMode) {
// 停止周期性断食
dispatch(stopFastingCycle());
} else {
// 清除单次断食
dispatch(clearActiveSchedule());
}
};
// 新增:启动周期性断食
const handleStartCycle = async (plan: FastingPlan, startHour: number, startMinute: number) => {
try {
dispatch(startFastingCycle({
planId: plan.id,
startHour,
startMinute
}));
// 等待数据保存完成
// 注意dispatch 是同步的,但我们需要确保数据被正确保存
console.log('周期性断食计划已启动', {
planId: plan.id,
startHour,
startMinute
});
} catch (error) {
console.error('启动周期性断食失败', error);
// TODO: 添加用户错误提示
}
};
// 新增:暂停/恢复周期性断食
const handleToggleCycle = async () => {
if (!activeCycle) return;
try {
if (activeCycle.enabled) {
dispatch(pauseFastingCycle());
console.log('周期性断食已暂停');
} else {
dispatch(resumeFastingCycle());
console.log('周期性断食已恢复');
}
} catch (error) {
console.error('切换周期性断食状态失败', error);
// TODO: 添加用户错误提示
}
};
return (
<View style={[styles.safeArea]}>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={[styles.scrollContainer, {
paddingTop: insets.top,
paddingBottom: 120
}]}
showsVerticalScrollIndicator={false}
>
<View style={styles.headerRow}>
<Text style={styles.screenTitle}></Text>
<Text style={styles.screenSubtitle}> · · </Text>
</View>
{/* 通知错误提示 */}
<NotificationErrorAlert
error={notificationError}
onRetry={forceSync}
onDismiss={clearError}
/>
{currentPlan ? (
<FastingOverviewCard
plan={currentPlan}
phaseLabel={getPhaseLabel(phase)}
countdownLabel={phase === 'fasting' ? '距离进食还有' : '距离断食还有'}
countdownValue={countdownValue}
startDayLabel={displayWindow.startDayLabel}
startTimeLabel={displayWindow.startTimeLabel}
endDayLabel={displayWindow.endDayLabel}
endTimeLabel={displayWindow.endTimeLabel}
onAdjustStartPress={handleAdjustStart}
onViewMealsPress={handleViewMeal}
onResetPress={handleResetPlan}
progress={progress}
/>
) : (
<View style={styles.emptyStateCard}>
<View style={styles.emptyStateHeader}>
<Text style={styles.emptyStateTitle}></Text>
<Text style={styles.emptyStateSubtitle}></Text>
</View>
<View style={styles.emptyStateContent}>
<View style={styles.emptyStateIcon}>
<Ionicons name="time-outline" size={48} color="#6F7D87" />
</View>
<Text style={styles.emptyStateDescription}>
</Text>
<Text style={styles.defaultPlanInfo}>
使 14-10 1410
</Text>
</View>
<View style={styles.emptyStateActions}>
<TouchableOpacity
style={styles.primaryButton}
onPress={() => setShowPicker(true)}
activeOpacity={0.8}
>
<Text style={styles.primaryButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => {
// 滚动到计划列表
setTimeout(() => {
scrollViewRef.current?.scrollTo({ y: 600, animated: true });
}, 100);
}}
activeOpacity={0.8}
>
<Text style={styles.secondaryButtonText}></Text>
</TouchableOpacity>
</View>
</View>
)}
{currentPlan && (
<View style={styles.highlightCard}>
<View style={styles.highlightHeader}>
<Text style={styles.highlightTitle}></Text>
<Text style={styles.highlightSubtitle}>{currentPlan.subtitle}</Text>
</View>
{currentPlan.highlights.map((highlight) => (
<View key={highlight} style={styles.highlightItem}>
<View style={[styles.highlightDot, { backgroundColor: currentPlan.theme.accent }]} />
<Text style={styles.highlightText}>{highlight}</Text>
</View>
))}
<View style={styles.resetRow}>
<Text style={styles.resetHint}>
</Text>
</View>
</View>
)}
<FastingPlanList
plans={FASTING_PLANS}
activePlanId={activePlan?.id ?? currentPlan?.id}
onSelectPlan={handleSelectPlan}
/>
{/* 参考文献入口 */}
<View style={styles.referencesSection}>
<TouchableOpacity
style={styles.referencesButton}
onPress={() => router.push(ROUTES.FASTING_REFERENCES)}
activeOpacity={0.8}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.referencesGlass}
glassEffectStyle="clear"
tintColor="rgba(46, 49, 66, 0.05)"
isInteractive={true}
>
<View style={styles.referencesContent}>
<Ionicons name="library-outline" size={20} color="#2E3142" />
<Text style={styles.referencesText}></Text>
<Ionicons name="chevron-forward" size={16} color="#6F7D87" />
</View>
</GlassView>
) : (
<View style={[styles.referencesGlass, styles.referencesFallback]}>
<View style={styles.referencesContent}>
<Ionicons name="library-outline" size={20} color="#2E3142" />
<Text style={styles.referencesText}></Text>
<Ionicons name="chevron-forward" size={16} color="#6F7D87" />
</View>
</View>
)}
</TouchableOpacity>
</View>
</ScrollView>
<FastingStartPickerModal
visible={showPicker}
onClose={() => setShowPicker(false)}
initialDate={scheduleStart}
recommendedDate={recommendedDate}
onConfirm={handleConfirmStart}
/>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: 'white'
},
scrollContainer: {
paddingHorizontal: 20,
paddingTop: 12,
},
headerRow: {
marginBottom: 20,
},
screenTitle: {
fontSize: 28,
fontWeight: '800',
color: '#2E3142',
marginBottom: 6,
},
screenSubtitle: {
fontSize: 14,
color: '#6F7D87',
fontWeight: '500',
},
highlightCard: {
marginTop: 28,
padding: 20,
borderRadius: 24,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.06,
shadowRadius: 20,
elevation: 4,
},
highlightHeader: {
marginBottom: 12,
},
highlightTitle: {
fontSize: 18,
fontWeight: '700',
color: '#2E3142',
},
highlightSubtitle: {
fontSize: 13,
color: '#6F7D87',
marginTop: 6,
},
highlightItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 10,
},
highlightDot: {
width: 6,
height: 6,
borderRadius: 3,
marginTop: 7,
marginRight: 10,
},
highlightText: {
flex: 1,
fontSize: 14,
color: '#4A5460',
lineHeight: 20,
},
resetRow: {
marginTop: 16,
},
resetHint: {
fontSize: 12,
color: '#8A96A3',
},
emptyStateCard: {
borderRadius: 28,
padding: 24,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 16 },
shadowOpacity: 0.08,
shadowRadius: 24,
elevation: 6,
marginBottom: 20,
},
emptyStateHeader: {
alignItems: 'center',
marginBottom: 24,
},
emptyStateTitle: {
fontSize: 24,
fontWeight: '700',
color: '#2E3142',
marginBottom: 8,
textAlign: 'center',
},
emptyStateSubtitle: {
fontSize: 16,
color: '#6F7D87',
textAlign: 'center',
lineHeight: 22,
},
emptyStateContent: {
alignItems: 'center',
marginBottom: 32,
},
emptyStateIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: 'rgba(111, 125, 135, 0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
},
emptyStateDescription: {
fontSize: 15,
color: '#4A5460',
textAlign: 'center',
lineHeight: 22,
paddingHorizontal: 20,
marginBottom: 12,
},
defaultPlanInfo: {
fontSize: 13,
color: '#8A96A3',
textAlign: 'center',
lineHeight: 18,
paddingHorizontal: 20,
fontStyle: 'italic',
},
emptyStateActions: {
gap: 12,
},
primaryButton: {
backgroundColor: '#2E3142',
paddingVertical: 16,
paddingHorizontal: 24,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
secondaryButton: {
backgroundColor: 'transparent',
paddingVertical: 16,
paddingHorizontal: 24,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1.5,
borderColor: '#2E3142',
},
secondaryButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#2E3142',
},
referencesSection: {
marginTop: 24,
marginBottom: 20,
},
referencesButton: {
borderRadius: 20,
overflow: 'hidden',
},
referencesGlass: {
borderRadius: 20,
paddingVertical: 16,
paddingHorizontal: 20,
},
referencesFallback: {
backgroundColor: 'rgba(246, 248, 250, 0.8)',
borderWidth: 1,
borderColor: 'rgba(46, 49, 66, 0.1)',
},
referencesContent: {
flexDirection: 'row',
alignItems: 'center',
},
referencesText: {
flex: 1,
fontSize: 16,
fontWeight: '600',
color: '#2E3142',
marginLeft: 12,
marginRight: 8,
},
});