diff --git a/app/(tabs)/fasting.tsx b/app/(tabs)/fasting.tsx index 3662e78..638693e 100644 --- a/app/(tabs)/fasting.tsx +++ b/app/(tabs)/fasting.tsx @@ -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(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(activePlan?.id ?? undefined); + const [preferredPlanId, setPreferredPlanId] = useState(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(0); + const lastCycleVerifyTimeRef = React.useRef(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 ( - {currentPlan && ( + {currentPlan ? ( + ) : ( + + + 开始您的断食之旅 + 选择适合的断食计划,开启健康生活 + + + + + + + + 断食可以帮助改善代谢、控制体重,让身体获得充分的休息和修复时间。 + + + 默认使用 14-10 热门计划(断食14小时,进食10小时) + + + + + setShowPicker(true)} + activeOpacity={0.8} + > + 开始断食计划 + + + { + // 滚动到计划列表 + setTimeout(() => { + scrollViewRef.current?.scrollTo({ y: 600, animated: true }); + }, 100); + }} + activeOpacity={0.8} + > + 浏览断食方案 + + + )} {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', + }, }); diff --git a/hooks/useFastingCycleNotifications.ts b/hooks/useFastingCycleNotifications.ts new file mode 100644 index 0000000..0ab22fc --- /dev/null +++ b/hooks/useFastingCycleNotifications.ts @@ -0,0 +1,175 @@ +import { FastingPlan } from '@/constants/Fasting'; +import { FastingCycleNotificationManager } from '@/services/fastingCycleNotifications'; +import { FastingCycle, FastingCycleSession } from '@/store/fastingSlice'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +export interface UseFastingCycleNotificationsState { + isReady: boolean; + isLoading: boolean; + error: string | null; + lastSyncTime: Date | null; +} + +export interface UseFastingCycleNotificationsActions { + verifyAndSync: () => Promise; + forceSync: () => Promise; + clearError: () => void; +} + +export const useFastingCycleNotifications = ( + cycle: FastingCycle | null, + session: FastingCycleSession | null, + plan: FastingPlan | undefined +): UseFastingCycleNotificationsState & UseFastingCycleNotificationsActions => { + const [state, setState] = useState({ + isReady: false, + isLoading: true, + error: null, + lastSyncTime: null, + }); + + const isInitializedRef = useRef(false); + const isSyncingRef = useRef(false); + + // 初始化通知系统 + const initialize = useCallback(async () => { + if (isInitializedRef.current) return; + + try { + setState(prev => ({ ...prev, isLoading: true, error: null })); + + // 周期性断食通知不需要额外的权限检查,因为它使用相同的通知系统 + setState(prev => ({ + ...prev, + isReady: true, + isLoading: false, + })); + + isInitializedRef.current = true; + } catch (error) { + console.error('初始化周期性断食通知失败', error); + setState(prev => ({ + ...prev, + isReady: false, + isLoading: false, + error: error instanceof Error ? error.message : '初始化失败', + })); + } + }, []); + + // 验证和同步通知 + const verifyAndSync = useCallback(async () => { + if (!state.isReady || isSyncingRef.current) return; + + try { + isSyncingRef.current = true; + setState(prev => ({ ...prev, error: null })); + + if (cycle && session) { + const isValid = await FastingCycleNotificationManager.verifyCycleNotifications(cycle, session); + + if (!isValid) { + console.log('周期性断食通知需要重新同步'); + try { + await FastingCycleNotificationManager.scheduleCycleNotifications(cycle, session); + } catch (syncError) { + // 如果同步失败,记录错误但不阻止验证流程 + console.error('重新安排通知失败', syncError); + setState(prev => ({ + ...prev, + error: '重新安排通知失败:' + (syncError instanceof Error ? syncError.message : '未知错误'), + })); + return; + } + } + } else { + // 如果没有周期性计划,确保清理所有相关通知 + await FastingCycleNotificationManager.cancelAllCycleNotifications(); + } + + setState(prev => ({ + ...prev, + lastSyncTime: new Date(), + })); + } catch (error) { + console.error('验证周期性断食通知失败', error); + setState(prev => ({ + ...prev, + error: '同步周期性断食通知失败:' + (error instanceof Error ? error.message : '未知错误'), + })); + } finally { + isSyncingRef.current = false; + } + }, [state.isReady, cycle, session]); + + // 强制同步通知 + const forceSync = useCallback(async () => { + if (!state.isReady) { + console.warn('通知系统未就绪,无法强制同步'); + return; + } + + if (isSyncingRef.current) { + console.warn('通知同步正在进行中,请稍后再试'); + return; + } + + try { + isSyncingRef.current = true; + setState(prev => ({ ...prev, error: null })); + + if (cycle && session) { + await FastingCycleNotificationManager.scheduleCycleNotifications(cycle, session); + console.log('周期性断食通知已强制同步', { + cycle: cycle.planId, + session: session.cycleDate, + }); + } else { + await FastingCycleNotificationManager.cancelAllCycleNotifications(); + } + + setState(prev => ({ + ...prev, + lastSyncTime: new Date(), + })); + } catch (error) { + console.error('强制同步周期性断食通知失败', error); + setState(prev => ({ + ...prev, + error: '强制同步周期性断食通知失败:' + (error instanceof Error ? error.message : '未知错误'), + })); + throw error; // 重新抛出错误以便调用方处理 + } finally { + isSyncingRef.current = false; + } + }, [state.isReady, cycle, session]); + + // 清除错误 + const clearError = useCallback(() => { + setState(prev => ({ ...prev, error: null })); + }, []); + + // 初始化 + useEffect(() => { + initialize(); + }, [initialize]); + + // 当周期性计划或会话变化时验证和同步 + useEffect(() => { + if (!state.isReady) return; + + // 使用防抖延迟执行,避免在快速状态变化时重复触发 + const debounceTimer = setTimeout(() => { + verifyAndSync(); + }, 1000); // 1秒防抖 + + return () => clearTimeout(debounceTimer); + }, [state.isReady, cycle?.planId, cycle?.enabled, session?.cycleDate, session?.startISO, session?.endISO]); + + return { + ...state, + verifyAndSync, + forceSync, + clearError, + }; +}; \ No newline at end of file diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index e313e72..90339f7 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -26,7 +26,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.24 + 1.0.25 CFBundleSignature ???? CFBundleURLTypes diff --git a/services/fastingCycleNotifications.ts b/services/fastingCycleNotifications.ts new file mode 100644 index 0000000..30f34b8 --- /dev/null +++ b/services/fastingCycleNotifications.ts @@ -0,0 +1,343 @@ +import dayjs from 'dayjs'; + +import { FastingPlan } from '@/constants/Fasting'; +import { FastingCycle, FastingCycleSession } from '@/store/fastingSlice'; +import { getNotificationEnabled } from '@/utils/userPreferences'; +import { notificationService, NotificationTypes } from './notifications'; + +const REMINDER_OFFSET_MINUTES = 30; // 提前30分钟提醒 + +// 通知同步锁,防止并发操作 +let notificationSyncLock = false; + +export class FastingCycleNotificationManager { + /** + * 为周期性断食计划安排通知 + * @param cycle 周期性断食计划 + * @param session 当前断食会话 + */ + static async scheduleCycleNotifications( + cycle: FastingCycle, + session: FastingCycleSession | null + ): Promise { + // 获取锁,防止并发操作 + if (notificationSyncLock) { + console.log('通知同步正在进行中,跳过本次操作'); + return; + } + + try { + notificationSyncLock = true; + + // 检查通知权限 + const notificationsEnabled = await getNotificationEnabled(); + console.log('🔔 周期性断食通知安排 - 权限检查:', { + notificationsEnabled, + cycleEnabled: cycle.enabled, + planId: cycle.planId + }); + + if (!notificationsEnabled) { + console.log('⚠️ 用户已关闭通知权限,跳过通知安排'); + return; + } + + if (!cycle.enabled) { + console.log('⚠️ 周期性断食已暂停,跳过通知安排'); + return; + } + + // 如果没有当前会话,不安排通知 + if (!session) { + console.log('⚠️ 没有当前断食会话,跳过通知安排'); + return; + } + + const plan = await this.getPlanById(cycle.planId); + if (!plan) { + console.warn('❌ 未找到断食计划:', cycle.planId); + return; + } + + const now = dayjs(); + const start = dayjs(session.startISO); + const end = dayjs(session.endISO); + + console.log('📅 断食会话信息:', { + planTitle: plan.title, + cycleDate: session.cycleDate, + start: start.format('YYYY-MM-DD HH:mm'), + end: end.format('YYYY-MM-DD HH:mm'), + now: now.format('YYYY-MM-DD HH:mm'), + }); + + // 如果断食期已结束,不安排通知 + if (end.isBefore(now)) { + console.log('⚠️ 断食期已结束,跳过通知安排'); + return; + } + + // 取消之前的周期性通知 + console.log('🧹 取消之前的周期性通知...'); + await this.cancelAllCycleNotifications(); + + // 1. 安排开始前30分钟通知 + if (start.isAfter(now)) { + const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute'); + + if (preStart.isAfter(now)) { + try { + const notificationId = await notificationService.scheduleNotificationAtDate( + { + title: `${plan.title} 即将开始`, + body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`, + data: { + type: NotificationTypes.FASTING_START, + planId: plan.id, + subtype: 'cycle_pre_start', + cycleDate: session.cycleDate, + }, + sound: true, + priority: 'high', + }, + preStart.toDate() + ); + console.log(`✅ 已安排周期性断食开始前30分钟通知 [${notificationId}]: ${preStart.format('YYYY-MM-DD HH:mm')}`); + } catch (error) { + console.error('❌ 安排周期性断食开始前30分钟通知失败', error); + } + } else { + console.log(`⏭️ 跳过开始前30分钟通知(时间已过): ${preStart.format('YYYY-MM-DD HH:mm')}`); + } + + // 2. 安排开始时通知 + try { + const notificationId = await notificationService.scheduleNotificationAtDate( + { + title: `${plan.title} 开始了`, + body: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`, + data: { + type: NotificationTypes.FASTING_START, + planId: plan.id, + subtype: 'cycle_start', + cycleDate: session.cycleDate, + }, + sound: true, + priority: 'high', + }, + start.toDate() + ); + console.log(`✅ 已安排周期性断食开始时通知 [${notificationId}]: ${start.format('YYYY-MM-DD HH:mm')}`); + } catch (error) { + console.error('❌ 安排周期性断食开始时通知失败', error); + } + } + + // 3. 安排结束前30分钟通知 + if (end.isAfter(now)) { + const preEnd = end.subtract(REMINDER_OFFSET_MINUTES, 'minute'); + + if (preEnd.isAfter(now)) { + try { + const notificationId = await notificationService.scheduleNotificationAtDate( + { + title: '即将结束断食', + body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要结束 ${plan.title},准备高蛋白 + 低GI 的餐食奖励自己!`, + data: { + type: NotificationTypes.FASTING_END, + planId: plan.id, + subtype: 'cycle_pre_end', + cycleDate: session.cycleDate, + }, + sound: true, + priority: 'high', + }, + preEnd.toDate() + ); + console.log(`✅ 已安排周期性断食结束前30分钟通知 [${notificationId}]: ${preEnd.format('YYYY-MM-DD HH:mm')}`); + } catch (error) { + console.error('❌ 安排周期性断食结束前30分钟通知失败', error); + } + } else { + console.log(`⏭️ 跳过结束前30分钟通知(时间已过): ${preEnd.format('YYYY-MM-DD HH:mm')}`); + } + + // 4. 安排结束时通知 + try { + const notificationId = await notificationService.scheduleNotificationAtDate( + { + title: '补能时刻到啦', + body: `${plan.title} 已完成!用一顿高蛋白 + 低GI 的餐食奖励自己,让代谢持续高效运转。`, + data: { + type: NotificationTypes.FASTING_END, + planId: plan.id, + subtype: 'cycle_end', + cycleDate: session.cycleDate, + }, + sound: true, + priority: 'high', + }, + end.toDate() + ); + console.log(`✅ 已安排周期性断食结束时通知 [${notificationId}]: ${end.format('YYYY-MM-DD HH:mm')}`); + } catch (error) { + console.error('❌ 安排周期性断食结束时通知失败', error); + } + } + + console.log('🎉 周期性断食通知安排完成'); + } catch (error) { + console.error('❌ 安排周期性断食通知失败', error); + throw error; // 抛出错误以便调用方处理 + } finally { + notificationSyncLock = false; + } + } + + /** + * 取消所有周期性断食通知 + * 只取消带有 cycle_ 前缀的通知,避免影响单次断食通知 + */ + static async cancelAllCycleNotifications(): Promise { + try { + const scheduledNotifications = await notificationService.getAllScheduledNotifications(); + + // 只过滤出周期性断食相关的通知(带有 cycle_ 前缀) + const cycleNotifications = scheduledNotifications.filter(notification => { + const data = notification.content.data as any; + return data && data.subtype?.startsWith('cycle_'); + }); + + // 取消所有周期性通知 + for (const notification of cycleNotifications) { + try { + await notificationService.cancelNotification(notification.identifier); + } catch (error) { + console.warn('取消周期性断食通知失败', error); + } + } + + console.log(`已取消 ${cycleNotifications.length} 个周期性断食通知`); + } catch (error) { + console.error('取消周期性断食通知失败', error); + } + } + + /** + * 更新每日通知(在断食结束后安排下一天的通知) + * @param cycle 周期性断食计划 + * @param nextSession 下一个断食会话 + */ + static async updateDailyNotifications( + cycle: FastingCycle, + nextSession: FastingCycleSession | null + ): Promise { + try { + // 取消当前的通知 + await this.cancelAllCycleNotifications(); + + // 安排下一天的通知 + if (cycle.enabled && nextSession) { + await this.scheduleCycleNotifications(cycle, nextSession); + } + } catch (error) { + console.error('更新每日断食通知失败', error); + } + } + + /** + * 验证周期性通知是否正确设置 + * @param cycle 周期性断食计划 + * @param session 当前断食会话 + */ + static async verifyCycleNotifications( + cycle: FastingCycle, + session: FastingCycleSession | null + ): Promise { + // 如果正在同步,直接返回 true 避免重复操作 + if (notificationSyncLock) { + console.log('通知同步正在进行中,跳过验证'); + return true; + } + + try { + if (!cycle.enabled || !session) { + // 如果没有启用周期性计划或没有会话,应该没有通知 + const scheduledNotifications = await notificationService.getAllScheduledNotifications(); + const cycleNotifications = scheduledNotifications.filter(notification => { + const data = notification.content.data as any; + return data && data.subtype?.startsWith('cycle_'); + }); + + if (cycleNotifications.length > 0) { + // 有不应该存在的通知,需要清理 + await this.cancelAllCycleNotifications(); + return false; + } + return true; + } + + const now = dayjs(); + const start = dayjs(session.startISO); + const end = dayjs(session.endISO); + + // 如果断食期已结束,应该没有通知 + if (end.isBefore(now)) { + await this.cancelAllCycleNotifications(); + return false; + } + + const scheduledNotifications = await notificationService.getAllScheduledNotifications(); + const cycleNotifications = scheduledNotifications.filter(notification => { + const data = notification.content.data as any; + return data && data.subtype?.startsWith('cycle_'); + }); + + // 检查是否有足够的通知 + const expectedNotifications = []; + + // 开始前30分钟通知 + if (start.isAfter(now)) { + const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute'); + if (preStart.isAfter(now)) { + expectedNotifications.push('cycle_pre_start'); + } + expectedNotifications.push('cycle_start'); + } + + // 结束前30分钟通知 + if (end.isAfter(now)) { + const preEnd = end.subtract(REMINDER_OFFSET_MINUTES, 'minute'); + if (preEnd.isAfter(now)) { + expectedNotifications.push('cycle_pre_end'); + } + expectedNotifications.push('cycle_end'); + } + + // 如果通知数量不匹配,标记为需要重新安排,但不在这里直接调用 + if (cycleNotifications.length !== expectedNotifications.length) { + console.log('周期性通知数量不匹配,需要重新安排'); + return false; + } + + return true; + } catch (error) { + console.error('验证周期性断食通知失败', error); + return false; + } + } + + /** + * 获取断食计划信息 + */ + private static async getPlanById(planId: string): Promise { + try { + // 这里需要导入 FASTING_PLANS,但为了避免循环依赖,我们使用动态导入 + const { FASTING_PLANS } = await import('@/constants/Fasting'); + return FASTING_PLANS.find(plan => plan.id === planId); + } catch (error) { + console.error('获取断食计划失败', error); + return undefined; + } + } +} \ No newline at end of file diff --git a/services/notifications.ts b/services/notifications.ts index 34dc580..5346d71 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -308,6 +308,13 @@ export class NotificationService { date: Date ): Promise { try { + // 检查完整权限(系统权限 + 用户偏好) + const hasPermission = await this.hasFullNotificationPermission(); + if (!hasPermission) { + console.log('⚠️ 定时通知被系统权限或用户偏好设置阻止,跳过安排'); + return 'blocked_by_permission_or_preference'; + } + const notificationId = await Notifications.scheduleNotificationAsync({ content: { title: notification.title, @@ -323,10 +330,10 @@ export class NotificationService { } as DateTrigger, }); - console.log('定时通知已安排,ID:', notificationId); + console.log('✅ 定时通知已安排,ID:', notificationId, '时间:', date.toLocaleString('zh-CN')); return notificationId; } catch (error) { - console.error('安排定时通知失败:', error); + console.error('❌ 安排定时通知失败:', error); throw error; } } diff --git a/store/fastingSlice.ts b/store/fastingSlice.ts index b7a17d5..9501041 100644 --- a/store/fastingSlice.ts +++ b/store/fastingSlice.ts @@ -6,6 +6,7 @@ import type { RootState } from './index'; export type FastingScheduleOrigin = 'manual' | 'recommended' | 'quick-start' | 'auto'; +// 保持向后兼容的单次断食计划 export type FastingSchedule = { planId: string; startISO: string; @@ -15,14 +16,42 @@ export type FastingSchedule = { origin: FastingScheduleOrigin; }; +// 新的周期性断食计划 +export type FastingCycle = { + planId: string; + startHour: number; + startMinute: number; + enabled: boolean; + createdAtISO: string; + lastUpdatedISO: string; +}; + +// 周期性断食的单次会话 +export type FastingCycleSession = { + planId: string; + startISO: string; + endISO: string; + cycleDate: string; // YYYY-MM-DD + completed: boolean; +}; + type FastingState = { + // 保持向后兼容的单次计划 activeSchedule: FastingSchedule | null; history: FastingSchedule[]; + + // 新的周期性计划 + activeCycle: FastingCycle | null; + currentCycleSession: FastingCycleSession | null; + cycleHistory: FastingCycleSession[]; }; const initialState: FastingState = { activeSchedule: null, history: [], + activeCycle: null, + currentCycleSession: null, + cycleHistory: [], }; const fastingSlice = createSlice({ @@ -126,6 +155,131 @@ const fastingSlice = createSlice({ clearActiveSchedule: (state) => { state.activeSchedule = null; }, + + // 周期性断食计划相关的 actions + startFastingCycle: ( + state, + action: PayloadAction<{ + planId: string; + startHour: number; + startMinute: number; + }> + ) => { + const plan = getPlanById(action.payload.planId); + if (!plan) return; + + const nowISO = new Date().toISOString(); + state.activeCycle = { + planId: plan.id, + startHour: action.payload.startHour, + startMinute: action.payload.startMinute, + enabled: true, + createdAtISO: nowISO, + lastUpdatedISO: nowISO, + }; + + // 创建今天的断食会话,增加最小提前时间检查 + const today = dayjs(); + const todayStart = today.hour(action.payload.startHour).minute(action.payload.startMinute).second(0).millisecond(0); + + // 要求至少提前10分钟设置周期性断食 + const minAdvanceTime = 10; // 分钟 + const minStartTime = today.add(minAdvanceTime, 'minute'); + + // 如果今天的开始时间已过或太接近,则从明天开始 + const sessionStart = todayStart.isBefore(minStartTime) + ? todayStart.add(1, 'day') + : todayStart; + + const { start, end } = calculateFastingWindow(sessionStart.toDate(), plan.fastingHours); + + state.currentCycleSession = { + planId: plan.id, + startISO: start.toISOString(), + endISO: end.toISOString(), + cycleDate: sessionStart.format('YYYY-MM-DD'), + completed: false, + }; + }, + + pauseFastingCycle: (state) => { + if (state.activeCycle) { + state.activeCycle.enabled = false; + state.activeCycle.lastUpdatedISO = new Date().toISOString(); + } + }, + + resumeFastingCycle: (state) => { + if (state.activeCycle) { + state.activeCycle.enabled = true; + state.activeCycle.lastUpdatedISO = new Date().toISOString(); + } + }, + + stopFastingCycle: (state) => { + // 完成当前会话 + if (state.currentCycleSession) { + state.cycleHistory.unshift(state.currentCycleSession); + } + + // 清除周期性计划 + state.activeCycle = null; + state.currentCycleSession = null; + }, + + updateFastingCycleTime: ( + state, + action: PayloadAction<{ startHour: number; startMinute: number }> + ) => { + if (!state.activeCycle) return; + + state.activeCycle.startHour = action.payload.startHour; + state.activeCycle.startMinute = action.payload.startMinute; + state.activeCycle.lastUpdatedISO = new Date().toISOString(); + }, + + completeCurrentCycleSession: (state) => { + if (!state.currentCycleSession) return; + + // 标记当前会话为已完成 + state.currentCycleSession.completed = true; + + // 添加到历史记录 + state.cycleHistory.unshift(state.currentCycleSession); + + // 创建下一个周期的会话 + if (state.activeCycle && state.activeCycle.enabled) { + const plan = getPlanById(state.activeCycle.planId); + if (plan) { + const nextDate = dayjs(state.currentCycleSession.cycleDate).add(1, 'day'); + const nextStart = nextDate.hour(state.activeCycle.startHour).minute(state.activeCycle.startMinute).second(0).millisecond(0); + const { start, end } = calculateFastingWindow(nextStart.toDate(), plan.fastingHours); + + state.currentCycleSession = { + planId: plan.id, + startISO: start.toISOString(), + endISO: end.toISOString(), + cycleDate: nextDate.format('YYYY-MM-DD'), + completed: false, + }; + } + } else { + state.currentCycleSession = null; + } + }, + + hydrateFastingCycle: ( + state, + action: PayloadAction<{ + activeCycle: FastingCycle | null; + currentCycleSession: FastingCycleSession | null; + cycleHistory: FastingCycleSession[]; + }> + ) => { + state.activeCycle = action.payload.activeCycle; + state.currentCycleSession = action.payload.currentCycleSession; + state.cycleHistory = action.payload.cycleHistory; + }, }, }); @@ -136,6 +290,14 @@ export const { setRecommendedSchedule, completeActiveSchedule, clearActiveSchedule, + // 周期性断食相关的 actions + startFastingCycle, + pauseFastingCycle, + resumeFastingCycle, + stopFastingCycle, + updateFastingCycleTime, + completeCurrentCycleSession, + hydrateFastingCycle, } = fastingSlice.actions; export default fastingSlice.reducer; @@ -148,3 +310,56 @@ export const selectActiveFastingPlan = (state: RootState): FastingPlan | undefin if (!schedule) return undefined; return getPlanById(schedule.planId); }; + +// 周期性断食相关的 selectors +export const selectActiveFastingCycle = (state: RootState) => state.fasting.activeCycle; +export const selectCurrentCycleSession = (state: RootState) => state.fasting.currentCycleSession; +export const selectCycleHistory = (state: RootState) => state.fasting.cycleHistory; +export const selectActiveCyclePlan = (state: RootState): FastingPlan | undefined => { + const cycle = state.fasting.activeCycle; + if (!cycle) return undefined; + return getPlanById(cycle.planId); +}; +export const selectCurrentCyclePlan = (state: RootState): FastingPlan | undefined => { + const session = state.fasting.currentCycleSession; + if (!session) return undefined; + return getPlanById(session.planId); +}; + +// 获取当前应该显示的断食信息(优先显示周期性,其次显示单次) +export const selectCurrentFastingPlan = (state: RootState): FastingPlan | undefined => { + // 优先显示周期性断食 + const cyclePlan = selectCurrentCyclePlan(state); + if (cyclePlan) return cyclePlan; + + // 其次显示单次断食 + return selectActiveFastingPlan(state); +}; + +// 获取当前应该显示的断食时间 +export const selectCurrentFastingTimes = (state: RootState) => { + // 优先显示周期性断食 + const cycleSession = state.fasting.currentCycleSession; + if (cycleSession) { + return { + startISO: cycleSession.startISO, + endISO: cycleSession.endISO, + }; + } + + // 其次显示单次断食 + const schedule = state.fasting.activeSchedule; + if (schedule) { + return { + startISO: schedule.startISO, + endISO: schedule.endISO, + }; + } + + return null; +}; + +// 判断是否处于周期性断食模式 +export const selectIsInCycleMode = (state: RootState) => { + return !!state.fasting.activeCycle; +}; diff --git a/utils/fasting.ts b/utils/fasting.ts index 1bdf2a1..7ec987c 100644 --- a/utils/fasting.ts +++ b/utils/fasting.ts @@ -255,3 +255,153 @@ export const clearFastingNotificationIds = async () => { console.warn('清除断食通知ID失败', error); } }; + +// 周期性断食相关的存储函数 +export const loadActiveFastingCycle = async (): Promise => { + try { + const stored = await AsyncStorage.getItem('@fasting_active_cycle'); + if (!stored) return null; + + const parsed = JSON.parse(stored); + return parsed; + } catch (error) { + console.warn('读取周期性断食计划失败', error); + return null; + } +}; + +export const saveActiveFastingCycle = async (cycle: any | null): Promise => { + try { + if (cycle) { + await AsyncStorage.setItem('@fasting_active_cycle', JSON.stringify(cycle)); + } else { + await AsyncStorage.removeItem('@fasting_active_cycle'); + } + } catch (error) { + console.error('保存周期性断食计划失败', error); + throw new Error('保存周期性断食计划失败,请稍后重试'); + } +}; + +export const loadCurrentCycleSession = async (): Promise => { + try { + const stored = await AsyncStorage.getItem('@fasting_current_cycle_session'); + if (!stored) return null; + + const parsed = JSON.parse(stored); + return parsed; + } catch (error) { + console.warn('读取当前断食会话失败', error); + return null; + } +}; + +export const saveCurrentCycleSession = async (session: any | null): Promise => { + try { + if (session) { + await AsyncStorage.setItem('@fasting_current_cycle_session', JSON.stringify(session)); + } else { + await AsyncStorage.removeItem('@fasting_current_cycle_session'); + } + } catch (error) { + console.error('保存当前断食会话失败', error); + throw new Error('保存断食会话失败,请稍后重试'); + } +}; + +export const loadCycleHistory = async (): Promise => { + try { + const stored = await AsyncStorage.getItem('@fasting_cycle_history'); + if (!stored) return []; + + const parsed = JSON.parse(stored); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + console.warn('读取断食周期历史失败', error); + return []; + } +}; + +export const saveCycleHistory = async (history: any[]): Promise => { + try { + await AsyncStorage.setItem('@fasting_cycle_history', JSON.stringify(history)); + } catch (error) { + console.error('保存断食周期历史失败', error); + throw new Error('保存断食历史失败,请稍后重试'); + } +}; + +// 计算下一个断食周期的开始时间 +export const calculateNextCycleStart = ( + cycle: { startHour: number; startMinute: number }, + baseDate: Date = new Date() +): Date => { + const now = dayjs(baseDate); + const today = now.startOf('day').hour(cycle.startHour).minute(cycle.startMinute).second(0).millisecond(0); + + // 如果今天的开始时间已过,则从明天开始 + if (today.isBefore(now)) { + return today.add(1, 'day').toDate(); + } + + return today.toDate(); +}; + +// 获取周期性断食的统计信息 +export const getCycleStats = (history: any[]) => { + const completedCycles = history.filter(session => session.completed); + const totalCycles = history.length; + const currentStreak = calculateCurrentStreak(history); + const longestStreak = calculateLongestStreak(history); + + return { + totalCycles, + completedCycles: completedCycles.length, + completionRate: totalCycles > 0 ? (completedCycles.length / totalCycles) * 100 : 0, + currentStreak, + longestStreak, + }; +}; + +// 计算当前连续完成天数 +const calculateCurrentStreak = (history: any[]): number => { + if (history.length === 0) return 0; + + let streak = 0; + const today = dayjs().startOf('day'); + + for (let i = 0; i < history.length; i++) { + const session = history[i]; + if (!session.completed) break; + + const sessionDate = dayjs(session.cycleDate); + const expectedDate = today.subtract(i, 'day'); + + if (sessionDate.isSame(expectedDate, 'day')) { + streak++; + } else { + break; + } + } + + return streak; +}; + +// 计算最长连续完成天数 +const calculateLongestStreak = (history: any[]): number => { + if (history.length === 0) return 0; + + let longestStreak = 0; + let currentStreak = 0; + + for (const session of history) { + if (session.completed) { + currentStreak++; + longestStreak = Math.max(longestStreak, currentStreak); + } else { + currentStreak = 0; + } + } + + return longestStreak; +};