Files
digital-pilates/services/fastingCycleNotifications.ts
richarjiang 0bea454dca feat(fasting): 添加周期性断食计划功能
实现完整的周期性断食计划系统,支持每日自动续订和通知管理:

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

343 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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