feat(fasting): 重构断食通知系统并增强可靠性

- 新增 useFastingNotifications hook 统一管理通知状态和同步逻辑
- 实现四阶段通知提醒:开始前30分钟、开始时、结束前30分钟、结束时
- 添加通知验证机制,确保通知正确设置和避免重复
- 新增 NotificationErrorAlert 组件显示通知错误并提供重试选项
- 实现断食计划持久化存储,应用重启后自动恢复
- 添加开发者测试面板用于验证通知系统可靠性
- 优化通知同步策略,支持选择性更新减少不必要的操作
- 修复个人页面编辑按钮样式问题
- 更新应用版本号至 1.0.18
This commit is contained in:
richarjiang
2025-10-14 15:05:11 +08:00
parent e03b2b3032
commit cf069f3537
18 changed files with 1565 additions and 242 deletions

View File

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