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,
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,
// 周期性断食相关的工具函数
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 defaultPlan = FASTING_PLANS.find((plan) => plan.id === '14-10') ?? FASTING_PLANS[0];
const [preferredPlanId, setPreferredPlanId] = useState<string | undefined>(activePlan?.id ?? undefined);
// 周期性断食计划的状态
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 (!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 = () => {
// 如果没有活跃计划,不执行任何操作
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',
},
});

View File

@@ -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<void>;
forceSync: () => Promise<void>;
clearError: () => void;
}
export const useFastingCycleNotifications = (
cycle: FastingCycle | null,
session: FastingCycleSession | null,
plan: FastingPlan | undefined
): UseFastingCycleNotificationsState & UseFastingCycleNotificationsActions => {
const [state, setState] = useState<UseFastingCycleNotificationsState>({
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,
};
};

View File

@@ -26,7 +26,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.24</string>
<string>1.0.25</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -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<void> {
// 获取锁,防止并发操作
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<void> {
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<void> {
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<boolean> {
// 如果正在同步,直接返回 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<FastingPlan | undefined> {
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;
}
}
}

View File

@@ -308,6 +308,13 @@ export class NotificationService {
date: Date
): Promise<string> {
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;
}
}

View File

@@ -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;
};

View File

@@ -255,3 +255,153 @@ export const clearFastingNotificationIds = async () => {
console.warn('清除断食通知ID失败', error);
}
};
// 周期性断食相关的存储函数
export const loadActiveFastingCycle = async (): Promise<any | null> => {
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<void> => {
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<any | null> => {
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<void> => {
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<any[]> => {
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<void> => {
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;
};