feat(fasting): 添加周期性断食计划功能

实现完整的周期性断食计划系统,支持每日自动续订和通知管理:

- 新增周期性断食状态管理(activeCycle、currentCycleSession、cycleHistory)
- 实现周期性断食会话的自动完成和续订逻辑
- 添加独立的周期性断食通知系统,避免与单次断食通知冲突
- 支持暂停/恢复周期性断食计划
- 添加周期性断食数据持久化和水合功能
- 优化断食界面,优先显示周期性断食信息
- 新增空状态引导界面,提升用户体验
- 保持单次断食功能向后兼容
This commit is contained in:
richarjiang
2025-11-12 15:36:35 +08:00
parent 8687be10e8
commit 0bea454dca
7 changed files with 1317 additions and 55 deletions

View File

@@ -6,45 +6,84 @@ import { FASTING_PLANS, FastingPlan, getPlanById, getRecommendedStart } from '@/
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,
rescheduleActivePlan,
scheduleFastingPlan,
selectActiveFastingPlan,
selectActiveFastingSchedule,
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,
calculateFastingWindow,
getFastingPhase,
getPhaseLabel,
loadPreferredPlanId,
savePreferredPlanId
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 { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
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>(activePlan?.id ?? undefined);
const [preferredPlanId, setPreferredPlanId] = useState<string | undefined>(currentPlan?.id ?? undefined);
// 数据持久化
useEffect(() => {
if (!activePlan?.id) return;
setPreferredPlanId(activePlan.id);
void savePreferredPlanId(activePlan.id);
}, [activePlan?.id]);
if (!currentPlan?.id) return;
setPreferredPlanId(currentPlan.id);
void savePreferredPlanId(currentPlan.id);
}, [currentPlan?.id]);
useEffect(() => {
let cancelled = false;
@@ -53,7 +92,7 @@ export default function FastingTabScreen() {
try {
const savedPlanId = await loadPreferredPlanId();
if (cancelled) return;
if (activePlan?.id) return;
if (currentPlan?.id) return;
if (savedPlanId && getPlanById(savedPlanId)) {
setPreferredPlanId(savedPlanId);
}
@@ -66,15 +105,86 @@ export default function FastingTabScreen() {
return () => {
cancelled = true;
};
}, [activePlan?.id]);
}, [currentPlan?.id]);
const currentPlan: FastingPlan | undefined = useMemo(() => {
if (activePlan) return activePlan;
if (preferredPlanId) return getPlanById(preferredPlanId) ?? defaultPlan;
return defaultPlan;
}, [activePlan, preferredPlanId, defaultPlan]);
// 加载周期性断食数据
useEffect(() => {
let cancelled = false;
// 使用新的通知管理 hook
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,
@@ -84,11 +194,24 @@ export default function FastingTabScreen() {
verifyAndSync,
forceSync,
clearError,
} = useFastingNotifications(activeSchedule, currentPlan);
} = 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();
@@ -104,21 +227,35 @@ export default function FastingTabScreen() {
}, [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 (activeSchedule) return new Date(activeSchedule.startISO);
if (currentPlan) return getRecommendedStart(currentPlan);
if (currentTimes) {
return new Date(currentTimes.startISO);
}
return undefined;
}, [activeSchedule, currentPlan]);
}, [currentTimes]);
const scheduleEnd = useMemo(() => {
if (activeSchedule && currentPlan) {
return new Date(activeSchedule.endISO);
}
if (currentPlan && scheduleStart) {
return calculateFastingWindow(scheduleStart, currentPlan.fastingHours).end;
if (currentTimes) {
return new Date(currentTimes.endISO);
}
return undefined;
}, [activeSchedule, currentPlan, scheduleStart]);
}, [currentTimes]);
const phase = getFastingPhase(scheduleStart ?? null, scheduleEnd ?? null);
const countdownTarget = phase === 'fasting' ? scheduleEnd : scheduleStart;
@@ -147,29 +284,76 @@ export default function FastingTabScreen() {
}, [notificationError]);
const recommendedDate = useMemo(() => {
if (!currentPlan) return undefined;
return getRecommendedStart(currentPlan);
}, [currentPlan]);
const planToUse = currentPlan || defaultPlan;
return getRecommendedStart(planToUse);
}, [currentPlan, defaultPlan]);
// 调试信息(开发环境)
useEffect(() => {
if (__DEV__ && lastSyncTime) {
console.log('断食通知状态:', {
console.log('单次断食通知状态:', {
ready: notificationsReady,
loading: notificationsLoading,
error: notificationError,
notificationIds,
lastSyncTime,
schedule: activeSchedule?.startISO,
plan: currentPlan?.id,
plan: activePlan?.id,
});
}
}, [notificationsReady, notificationsLoading, notificationError, notificationIds, lastSyncTime, activeSchedule?.startISO, currentPlan?.id]);
}, [notificationsReady, notificationsLoading, notificationError, notificationIds, lastSyncTime, activeSchedule?.startISO, activePlan?.id]);
// 自动续订断食周期
// 修改为使用每日固定时间,而非相对时间计算
useEffect(() => {
if (!activeSchedule || !currentPlan) return;
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);
@@ -202,7 +386,7 @@ export default function FastingTabScreen() {
nextStart = nextStart.add(1, 'day');
}
const nextEnd = nextStart.add(currentPlan.fastingHours, 'hour');
const nextEnd = nextStart.add(activePlan.fastingHours, 'hour');
if (__DEV__) {
console.log('自动续订断食周期:', {
@@ -217,19 +401,27 @@ export default function FastingTabScreen() {
start: nextStart.toISOString(),
origin: 'auto',
}));
}, [dispatch, activeSchedule, currentPlan, phase]);
}, [dispatch, activeSchedule, activePlan, phase]);
const handleAdjustStart = () => {
if (!currentPlan) return;
setShowPicker(true);
};
const handleConfirmStart = (date: Date) => {
if (!currentPlan) return;
if (activeSchedule) {
// 如果没有当前计划,使用默认计划
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: currentPlan.id, start: date.toISOString(), origin: 'manual' }));
// 创建新的单次断食计划
dispatch(scheduleFastingPlan({ planId: planToUse.id, start: date.toISOString(), origin: 'manual' }));
}
};
@@ -242,12 +434,62 @@ export default function FastingTabScreen() {
};
const handleResetPlan = () => {
dispatch(clearActiveSchedule());
// 如果没有活跃计划,不执行任何操作
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
@@ -266,7 +508,7 @@ export default function FastingTabScreen() {
onDismiss={clearError}
/>
{currentPlan && (
{currentPlan ? (
<FastingOverviewCard
plan={currentPlan}
phaseLabel={getPhaseLabel(phase)}
@@ -281,6 +523,48 @@ export default function FastingTabScreen() {
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 && (
@@ -394,4 +678,92 @@ const styles = StyleSheet.create({
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',
},
});