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

487 lines
14 KiB
TypeScript
Raw 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 * as Notifications from 'expo-notifications';
import { FastingPlan } from '@/constants/Fasting';
import { FastingSchedule } from '@/store/fastingSlice';
import {
clearFastingNotificationIds,
FastingNotificationIds,
getFastingNotificationsRegistered,
loadStoredFastingNotificationIds,
saveFastingNotificationIds,
setFastingNotificationsRegistered,
} from '@/utils/fasting';
import { getNotificationEnabled } from '@/utils/userPreferences';
import { notificationService, NotificationTypes } from './notifications';
const REMINDER_OFFSET_MINUTES = 30; // 改为30分钟提醒
const cancelNotificationIds = async (ids?: FastingNotificationIds) => {
if (!ids) return;
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);
}
// 取消结束前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);
}
};
export const ensureFastingNotificationsReady = async (): Promise<boolean> => {
try {
const notificationsEnabled = await getNotificationEnabled();
if (!notificationsEnabled) {
return false;
}
const registered = await getFastingNotificationsRegistered();
if (registered) {
return true;
}
const status = await notificationService.getPermissionStatus();
if (status !== 'granted') {
const requestStatus = await notificationService.requestPermission();
if (requestStatus !== 'granted') {
return false;
}
}
await setFastingNotificationsRegistered(true);
return true;
} catch (error) {
console.warn('初始化断食通知失败', error);
return false;
}
};
type ResyncOptions = {
schedule: FastingSchedule | null;
plan?: FastingPlan;
previousIds?: FastingNotificationIds;
enabled: boolean;
};
export const resyncFastingNotifications = async ({
schedule,
plan,
previousIds,
enabled,
}: ResyncOptions): Promise<FastingNotificationIds> => {
const storedIds = previousIds ?? (await loadStoredFastingNotificationIds());
await cancelNotificationIds(storedIds);
if (!enabled) {
await setFastingNotificationsRegistered(false);
await clearFastingNotificationIds();
return {};
}
const preferenceEnabled = await getNotificationEnabled();
if (!preferenceEnabled) {
await setFastingNotificationsRegistered(false);
await clearFastingNotificationIds();
return {};
}
if (!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 notificationIds: FastingNotificationIds = {};
// 1. 安排开始前30分钟通知
if (start.isAfter(now)) {
const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute');
// 只有当开始前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: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`,
data: {
type: NotificationTypes.FASTING_START,
planId: plan.id,
subtype: 'start',
},
sound: true,
priority: 'high',
},
start.toDate()
);
notificationIds.startId = startId;
} catch (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(
{
title: '补能时刻到啦',
body: `${plan.title} 已完成!用一顿高蛋白 + 低GI 的餐食奖励自己,让代谢持续高效运转。`,
data: {
type: NotificationTypes.FASTING_END,
planId: plan.id,
subtype: 'end',
},
sound: true,
priority: 'high',
},
end.toDate()
);
notificationIds.endId = endId;
} catch (error) {
console.error('安排断食结束时通知失败', error);
}
}
await saveFastingNotificationIds(notificationIds);
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 };