Files
digital-pilates/services/fastingNotifications.ts
richarjiang ea22901553 feat(background-task): 完善iOS后台任务系统并优化断食通知和UI体验
- 修复iOS后台任务注册时机问题,确保任务能正常触发
- 添加后台任务调试辅助工具和完整测试指南
- 优化断食通知系统,增加防抖机制避免频繁重调度
- 改进断食自动续订逻辑,使用固定时间而非相对时间计算
- 优化统计页面布局,添加身体指标section标题
- 增强饮水详情页面视觉效果,改进卡片样式和配色
- 添加用户反馈入口到个人设置页面
- 完善锻炼摘要卡片条件渲染逻辑
- 增强日志记录和错误处理机制

这些改进显著提升了应用的稳定性、性能和用户体验,特别是在iOS后台任务执行和断食功能方面。
2025-11-05 11:23:33 +08:00

527 lines
16 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 * 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.subtract(1, 'hour'))) {
await clearFastingNotificationIds();
return {};
}
const updatedIds: FastingNotificationIds = { ...validIds };
// 先取消所有无效的旧通知,避免重复
const invalidIds = Object.entries(storedIds).filter(
([key, id]) => id && !Object.values(validIds).includes(id)
);
for (const [_, id] of invalidIds) {
if (id) {
try {
await notificationService.cancelNotification(id);
} catch (error) {
console.warn('取消无效通知失败', error);
}
}
}
// 1. 检查开始前30分钟通知
const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute');
if (preStart.isAfter(now.add(1, 'minute')) && !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;
console.log(`已安排断食开始前30分钟通知: ${preStart.format('YYYY-MM-DD HH:mm')}`);
} catch (error) {
console.error('安排断食开始前30分钟通知失败', error);
}
}
// 2. 检查开始时通知
if (start.isAfter(now.add(1, 'minute')) && !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;
console.log(`已安排断食开始时通知: ${start.format('YYYY-MM-DD HH:mm')}`);
} catch (error) {
console.error('安排断食开始时通知失败', error);
}
}
// 3. 检查结束前30分钟通知
const preEnd = end.subtract(REMINDER_OFFSET_MINUTES, 'minute');
if (preEnd.isAfter(now.add(1, 'minute')) && !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;
console.log(`已安排断食结束前30分钟通知: ${preEnd.format('YYYY-MM-DD HH:mm')}`);
} catch (error) {
console.error('安排断食结束前30分钟通知失败', error);
}
}
// 4. 检查结束时通知
if (end.isAfter(now.add(1, 'minute')) && !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;
console.log(`已安排断食结束时通知: ${end.format('YYYY-MM-DD HH:mm')}`);
} 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);
// 如果断食期已结束超过1小时应该清空所有通知
// 这样可以避免在自动续订过程中过早清空通知
if (end.isBefore(now.subtract(1, 'hour'))) {
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) {
// 跳过已过期的通知过期超过5分钟
if (expected.time.isBefore(now.subtract(5, 'minute'))) {
if (expected.id) {
// 取消已过期的通知
try {
await notificationService.cancelNotification(expected.id);
} catch (error) {
console.warn('取消过期通知失败', error);
}
needsResync = true;
}
continue;
}
if (!expected.id) {
needsResync = true;
continue;
}
// 检查通知是否还存在
const exists = scheduledNotifications.some(n => n.identifier === expected.id);
if (!exists) {
needsResync = true;
continue;
}
// 检查通知时间是否匹配容差1分钟
const notification = scheduledNotifications.find(n => n.identifier === expected.id);
if (notification?.trigger && 'date' in notification.trigger) {
const scheduledTime = dayjs(notification.trigger.date);
const timeDiff = Math.abs(scheduledTime.diff(expected.time, 'minute'));
// 如果时间差异超过1分钟说明需要重新安排
if (timeDiff > 1) {
console.log(`通知时间不匹配,需要重新安排: ${expected.type}, 期望: ${expected.time.format('HH:mm')}, 实际: ${scheduledTime.format('HH:mm')}`);
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 };