feat(fasting): 重构断食通知系统并增强可靠性
- 新增 useFastingNotifications hook 统一管理通知状态和同步逻辑 - 实现四阶段通知提醒:开始前30分钟、开始时、结束前30分钟、结束时 - 添加通知验证机制,确保通知正确设置和避免重复 - 新增 NotificationErrorAlert 组件显示通知错误并提供重试选项 - 实现断食计划持久化存储,应用重启后自动恢复 - 添加开发者测试面板用于验证通知系统可靠性 - 优化通知同步策略,支持选择性更新减少不必要的操作 - 修复个人页面编辑按钮样式问题 - 更新应用版本号至 1.0.18
This commit is contained in:
@@ -1,37 +1,59 @@
|
||||
import dayjs from 'dayjs';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
|
||||
import { FastingPlan } from '@/constants/Fasting';
|
||||
import { FastingSchedule } from '@/store/fastingSlice';
|
||||
import {
|
||||
clearFastingNotificationIds,
|
||||
FastingNotificationIds,
|
||||
getFastingNotificationsRegistered,
|
||||
loadStoredFastingNotificationIds,
|
||||
saveFastingNotificationIds,
|
||||
setFastingNotificationsRegistered,
|
||||
FastingNotificationIds,
|
||||
} from '@/utils/fasting';
|
||||
import { getNotificationEnabled } from '@/utils/userPreferences';
|
||||
import { notificationService, NotificationTypes } from './notifications';
|
||||
|
||||
const REMINDER_OFFSET_MINUTES = 10;
|
||||
const REMINDER_OFFSET_MINUTES = 30; // 改为30分钟提醒
|
||||
|
||||
const cancelNotificationIds = async (ids?: FastingNotificationIds) => {
|
||||
if (!ids) return;
|
||||
const { startId, endId } = ids;
|
||||
const { preStartId, startId, preEndId, endId } = ids;
|
||||
|
||||
// 取消开始前30分钟通知
|
||||
try {
|
||||
if (preStartId) {
|
||||
await notificationService.cancelNotification(preStartId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('取消断食开始前30分钟提醒失败', error);
|
||||
}
|
||||
|
||||
// 取消开始时通知
|
||||
try {
|
||||
if (startId) {
|
||||
await notificationService.cancelNotification(startId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('取消断食开始提醒失败', error);
|
||||
console.warn('取消断食开始时提醒失败', error);
|
||||
}
|
||||
|
||||
// 取消结束前30分钟通知
|
||||
try {
|
||||
if (preEndId) {
|
||||
await notificationService.cancelNotification(preEndId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('取消断食结束前30分钟提醒失败', error);
|
||||
}
|
||||
|
||||
// 取消结束时通知
|
||||
try {
|
||||
if (endId) {
|
||||
await notificationService.cancelNotification(endId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('取消断食结束提醒失败', error);
|
||||
console.warn('取消断食结束时提醒失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,33 +130,83 @@ export const resyncFastingNotifications = async ({
|
||||
|
||||
const notificationIds: FastingNotificationIds = {};
|
||||
|
||||
// 1. 安排开始前30分钟通知
|
||||
if (start.isAfter(now)) {
|
||||
const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
||||
const triggerMoment = preStart.isAfter(now) ? preStart : start;
|
||||
|
||||
// 只有当开始前30分钟还在未来时才安排这个通知
|
||||
if (preStart.isAfter(now)) {
|
||||
try {
|
||||
const preStartId = await notificationService.scheduleNotificationAtDate(
|
||||
{
|
||||
title: `${plan.title} 即将开始`,
|
||||
body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`,
|
||||
data: {
|
||||
type: NotificationTypes.FASTING_START,
|
||||
planId: plan.id,
|
||||
subtype: 'pre_start',
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
},
|
||||
preStart.toDate()
|
||||
);
|
||||
notificationIds.preStartId = preStartId;
|
||||
} catch (error) {
|
||||
console.error('安排断食开始前30分钟通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 安排开始时通知
|
||||
try {
|
||||
const startId = await notificationService.scheduleNotificationAtDate(
|
||||
{
|
||||
title: `${plan.title} 即将开始`,
|
||||
body: preStart.isAfter(now)
|
||||
? `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`
|
||||
: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`,
|
||||
title: `${plan.title} 开始了`,
|
||||
body: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`,
|
||||
data: {
|
||||
type: NotificationTypes.FASTING_START,
|
||||
planId: plan.id,
|
||||
subtype: 'start',
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
},
|
||||
triggerMoment.toDate()
|
||||
start.toDate()
|
||||
);
|
||||
notificationIds.startId = startId;
|
||||
} catch (error) {
|
||||
console.error('安排断食开始通知失败', error);
|
||||
console.error('安排断食开始时通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 安排结束前30分钟通知
|
||||
if (end.isAfter(now)) {
|
||||
const preEnd = end.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
||||
|
||||
// 只有当结束前30分钟还在未来时才安排这个通知
|
||||
if (preEnd.isAfter(now)) {
|
||||
try {
|
||||
const preEndId = await notificationService.scheduleNotificationAtDate(
|
||||
{
|
||||
title: '即将结束断食',
|
||||
body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要结束 ${plan.title},准备高蛋白 + 低GI 的餐食奖励自己!`,
|
||||
data: {
|
||||
type: NotificationTypes.FASTING_END,
|
||||
planId: plan.id,
|
||||
subtype: 'pre_end',
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
},
|
||||
preEnd.toDate()
|
||||
);
|
||||
notificationIds.preEndId = preEndId;
|
||||
} catch (error) {
|
||||
console.error('安排断食结束前30分钟通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 安排结束时通知
|
||||
try {
|
||||
const endId = await notificationService.scheduleNotificationAtDate(
|
||||
{
|
||||
@@ -143,6 +215,7 @@ export const resyncFastingNotifications = async ({
|
||||
data: {
|
||||
type: NotificationTypes.FASTING_END,
|
||||
planId: plan.id,
|
||||
subtype: 'end',
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
@@ -151,7 +224,7 @@ export const resyncFastingNotifications = async ({
|
||||
);
|
||||
notificationIds.endId = endId;
|
||||
} catch (error) {
|
||||
console.error('安排断食结束通知失败', error);
|
||||
console.error('安排断食结束时通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,4 +232,255 @@ export const resyncFastingNotifications = async ({
|
||||
return notificationIds;
|
||||
};
|
||||
|
||||
/**
|
||||
* 选择性同步通知,只更新需要更新的通知
|
||||
* @param schedule 断食计划
|
||||
* @param plan 断食方案
|
||||
* @param storedIds 存储的通知ID
|
||||
* @param validIds 有效的通知ID
|
||||
* @param enabled 是否启用通知
|
||||
* @returns 更新后的通知ID
|
||||
*/
|
||||
const selectiveSyncNotifications = async ({
|
||||
schedule,
|
||||
plan,
|
||||
storedIds,
|
||||
validIds,
|
||||
enabled,
|
||||
}: {
|
||||
schedule: FastingSchedule | null;
|
||||
plan?: FastingPlan;
|
||||
storedIds: FastingNotificationIds;
|
||||
validIds: FastingNotificationIds;
|
||||
enabled: boolean;
|
||||
}): Promise<FastingNotificationIds> => {
|
||||
if (!enabled || !schedule || !plan) {
|
||||
await clearFastingNotificationIds();
|
||||
return {};
|
||||
}
|
||||
|
||||
const now = dayjs();
|
||||
const start = dayjs(schedule.startISO);
|
||||
const end = dayjs(schedule.endISO);
|
||||
|
||||
if (end.isBefore(now)) {
|
||||
await clearFastingNotificationIds();
|
||||
return {};
|
||||
}
|
||||
|
||||
const updatedIds: FastingNotificationIds = { ...validIds };
|
||||
|
||||
// 1. 检查开始前30分钟通知
|
||||
const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
||||
if (preStart.isAfter(now) && !validIds.preStartId) {
|
||||
try {
|
||||
const preStartId = await notificationService.scheduleNotificationAtDate(
|
||||
{
|
||||
title: `${plan.title} 即将开始`,
|
||||
body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`,
|
||||
data: {
|
||||
type: NotificationTypes.FASTING_START,
|
||||
planId: plan.id,
|
||||
subtype: 'pre_start',
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
},
|
||||
preStart.toDate()
|
||||
);
|
||||
updatedIds.preStartId = preStartId;
|
||||
} catch (error) {
|
||||
console.error('安排断食开始前30分钟通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查开始时通知
|
||||
if (start.isAfter(now) && !validIds.startId) {
|
||||
try {
|
||||
const startId = await notificationService.scheduleNotificationAtDate(
|
||||
{
|
||||
title: `${plan.title} 开始了`,
|
||||
body: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`,
|
||||
data: {
|
||||
type: NotificationTypes.FASTING_START,
|
||||
planId: plan.id,
|
||||
subtype: 'start',
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
},
|
||||
start.toDate()
|
||||
);
|
||||
updatedIds.startId = startId;
|
||||
} catch (error) {
|
||||
console.error('安排断食开始时通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查结束前30分钟通知
|
||||
const preEnd = end.subtract(REMINDER_OFFSET_MINUTES, 'minute');
|
||||
if (preEnd.isAfter(now) && !validIds.preEndId) {
|
||||
try {
|
||||
const preEndId = await notificationService.scheduleNotificationAtDate(
|
||||
{
|
||||
title: '即将结束断食',
|
||||
body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要结束 ${plan.title},准备高蛋白 + 低GI 的餐食奖励自己!`,
|
||||
data: {
|
||||
type: NotificationTypes.FASTING_END,
|
||||
planId: plan.id,
|
||||
subtype: 'pre_end',
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
},
|
||||
preEnd.toDate()
|
||||
);
|
||||
updatedIds.preEndId = preEndId;
|
||||
} catch (error) {
|
||||
console.error('安排断食结束前30分钟通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 检查结束时通知
|
||||
if (end.isAfter(now) && !validIds.endId) {
|
||||
try {
|
||||
const endId = await notificationService.scheduleNotificationAtDate(
|
||||
{
|
||||
title: '补能时刻到啦',
|
||||
body: `${plan.title} 已完成!用一顿高蛋白 + 低GI 的餐食奖励自己,让代谢持续高效运转。`,
|
||||
data: {
|
||||
type: NotificationTypes.FASTING_END,
|
||||
planId: plan.id,
|
||||
subtype: 'end',
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
},
|
||||
end.toDate()
|
||||
);
|
||||
updatedIds.endId = endId;
|
||||
} catch (error) {
|
||||
console.error('安排断食结束时通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
await saveFastingNotificationIds(updatedIds);
|
||||
return updatedIds;
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证现有通知是否正确设置
|
||||
* @param schedule 当前断食计划
|
||||
* @param plan 当前断食方案
|
||||
* @param storedIds 存储的通知ID
|
||||
* @returns 验证结果和需要更新的通知ID
|
||||
*/
|
||||
export const verifyFastingNotifications = async ({
|
||||
schedule,
|
||||
plan,
|
||||
storedIds,
|
||||
}: {
|
||||
schedule: FastingSchedule | null;
|
||||
plan?: FastingPlan;
|
||||
storedIds: FastingNotificationIds;
|
||||
}): Promise<{ isValid: boolean; updatedIds: FastingNotificationIds }> => {
|
||||
if (!schedule || !plan) {
|
||||
// 如果没有计划或方案,应该清空所有通知
|
||||
if (Object.values(storedIds).some(id => id)) {
|
||||
await cancelNotificationIds(storedIds);
|
||||
await clearFastingNotificationIds();
|
||||
return { isValid: false, updatedIds: {} };
|
||||
}
|
||||
return { isValid: true, updatedIds: storedIds };
|
||||
}
|
||||
|
||||
const now = dayjs();
|
||||
const start = dayjs(schedule.startISO);
|
||||
const end = dayjs(schedule.endISO);
|
||||
|
||||
// 如果断食期已结束,应该清空所有通知
|
||||
if (end.isBefore(now)) {
|
||||
if (Object.values(storedIds).some(id => id)) {
|
||||
await cancelNotificationIds(storedIds);
|
||||
await clearFastingNotificationIds();
|
||||
return { isValid: false, updatedIds: {} };
|
||||
}
|
||||
return { isValid: true, updatedIds: {} };
|
||||
}
|
||||
|
||||
// 获取当前已安排的通知
|
||||
let scheduledNotifications: Notifications.NotificationRequest[] = [];
|
||||
try {
|
||||
scheduledNotifications = await notificationService.getAllScheduledNotifications();
|
||||
} catch (error) {
|
||||
console.warn('获取已安排通知失败', error);
|
||||
// 如果获取通知失败,不要立即重新同步,而是返回当前存储的ID
|
||||
// 这样可以避免可能的重复通知问题
|
||||
return { isValid: false, updatedIds: storedIds };
|
||||
}
|
||||
|
||||
// 检查每个通知是否存在且正确
|
||||
const expectedNotifications = [
|
||||
{ id: storedIds.preStartId, type: 'pre_start', time: start.subtract(REMINDER_OFFSET_MINUTES, 'minute') },
|
||||
{ id: storedIds.startId, type: 'start', time: start },
|
||||
{ id: storedIds.preEndId, type: 'pre_end', time: end.subtract(REMINDER_OFFSET_MINUTES, 'minute') },
|
||||
{ id: storedIds.endId, type: 'end', time: end },
|
||||
];
|
||||
|
||||
let needsResync = false;
|
||||
const validIds: FastingNotificationIds = {};
|
||||
|
||||
for (const expected of expectedNotifications) {
|
||||
// 跳过已过期的通知
|
||||
if (expected.time.isBefore(now)) {
|
||||
if (expected.id) {
|
||||
needsResync = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!expected.id) {
|
||||
needsResync = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查通知是否还存在
|
||||
const exists = scheduledNotifications.some(n => n.identifier === expected.id);
|
||||
if (!exists) {
|
||||
needsResync = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 通知存在且有效
|
||||
switch (expected.type) {
|
||||
case 'pre_start':
|
||||
validIds.preStartId = expected.id;
|
||||
break;
|
||||
case 'start':
|
||||
validIds.startId = expected.id;
|
||||
break;
|
||||
case 'pre_end':
|
||||
validIds.preEndId = expected.id;
|
||||
break;
|
||||
case 'end':
|
||||
validIds.endId = expected.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsResync) {
|
||||
// 只同步需要更新的通知,而不是全部重新同步
|
||||
const updatedIds = await selectiveSyncNotifications({
|
||||
schedule,
|
||||
plan,
|
||||
storedIds,
|
||||
validIds,
|
||||
enabled: true
|
||||
});
|
||||
return { isValid: false, updatedIds };
|
||||
}
|
||||
|
||||
return { isValid: true, updatedIds: validIds };
|
||||
};
|
||||
|
||||
export type { FastingNotificationIds };
|
||||
|
||||
Reference in New Issue
Block a user