feat(fasting): 添加周期性断食计划功能
实现完整的周期性断食计划系统,支持每日自动续订和通知管理: - 新增周期性断食状态管理(activeCycle、currentCycleSession、cycleHistory) - 实现周期性断食会话的自动完成和续订逻辑 - 添加独立的周期性断食通知系统,避免与单次断食通知冲突 - 支持暂停/恢复周期性断食计划 - 添加周期性断食数据持久化和水合功能 - 优化断食界面,优先显示周期性断食信息 - 新增空状态引导界面,提升用户体验 - 保持单次断食功能向后兼容
This commit is contained in:
@@ -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 热门计划(断食14小时,进食10小时)
|
||||
</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',
|
||||
},
|
||||
});
|
||||
|
||||
175
hooks/useFastingCycleNotifications.ts
Normal file
175
hooks/useFastingCycleNotifications.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
343
services/fastingCycleNotifications.ts
Normal file
343
services/fastingCycleNotifications.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
150
utils/fasting.ts
150
utils/fasting.ts
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user