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

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