实现完整的周期性断食计划系统,支持每日自动续订和通知管理: - 新增周期性断食状态管理(activeCycle、currentCycleSession、cycleHistory) - 实现周期性断食会话的自动完成和续订逻辑 - 添加独立的周期性断食通知系统,避免与单次断食通知冲突 - 支持暂停/恢复周期性断食计划 - 添加周期性断食数据持久化和水合功能 - 优化断食界面,优先显示周期性断食信息 - 新增空状态引导界面,提升用户体验 - 保持单次断食功能向后兼容
609 lines
18 KiB
TypeScript
609 lines
18 KiB
TypeScript
import { ROUTES } from '@/constants/Routes';
|
||
import { getNotificationEnabled } from '@/utils/userPreferences';
|
||
import * as Notifications from 'expo-notifications';
|
||
import { router } from 'expo-router';
|
||
import { pushNotificationManager } from './pushNotificationManager';
|
||
|
||
// 配置通知处理方式
|
||
Notifications.setNotificationHandler({
|
||
handleNotification: async () => ({
|
||
shouldPlaySound: true,
|
||
shouldSetBadge: true,
|
||
shouldShowBanner: true,
|
||
shouldShowList: true,
|
||
}),
|
||
});
|
||
|
||
export interface NotificationData {
|
||
title: string;
|
||
body: string;
|
||
data?: Record<string, unknown>;
|
||
sound?: boolean;
|
||
priority?: 'default' | 'normal' | 'high';
|
||
vibrate?: number[];
|
||
}
|
||
|
||
interface DateTrigger {
|
||
type: Notifications.SchedulableTriggerInputTypes.DATE;
|
||
date: number;
|
||
}
|
||
|
||
interface RepeatingTrigger {
|
||
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL;
|
||
seconds: number;
|
||
repeats?: boolean;
|
||
}
|
||
|
||
interface DailyTrigger {
|
||
type: Notifications.SchedulableTriggerInputTypes.DAILY;
|
||
hour: number;
|
||
minute: number;
|
||
}
|
||
|
||
interface WeeklyTrigger {
|
||
type: Notifications.SchedulableTriggerInputTypes.WEEKLY;
|
||
hour: number;
|
||
minute: number;
|
||
weekday: number;
|
||
}
|
||
|
||
interface MonthlyTrigger {
|
||
type: Notifications.SchedulableTriggerInputTypes.MONTHLY;
|
||
hour: number;
|
||
minute: number;
|
||
day: number;
|
||
}
|
||
|
||
type CalendarTrigger = DailyTrigger | WeeklyTrigger | MonthlyTrigger;
|
||
|
||
export class NotificationService {
|
||
private static instance: NotificationService;
|
||
private isInitialized = false;
|
||
private isIniting = false
|
||
|
||
private constructor() { }
|
||
|
||
public static getInstance(): NotificationService {
|
||
if (!NotificationService.instance) {
|
||
NotificationService.instance = new NotificationService();
|
||
}
|
||
return NotificationService.instance;
|
||
}
|
||
|
||
/**
|
||
* 初始化推送通知服务
|
||
*/
|
||
async initialize(): Promise<void> {
|
||
if (this.isInitialized || this.isIniting) return;
|
||
|
||
try {
|
||
this.isIniting = true
|
||
|
||
// 初始化推送通知管理器(包含设备令牌管理)
|
||
const pushManagerInitialized = await pushNotificationManager.initialize({
|
||
onTokenReceived: (token) => {
|
||
console.log('设备令牌已接收:', token.substring(0, 20) + '...');
|
||
},
|
||
onTokenRefresh: (token) => {
|
||
console.log('设备令牌已刷新:', token.substring(0, 20) + '...');
|
||
},
|
||
onError: (error) => {
|
||
console.error('推送通知管理器错误:', error);
|
||
}
|
||
});
|
||
|
||
if (!pushManagerInitialized) {
|
||
console.warn('推送通知管理器初始化失败,但本地通知功能仍可用');
|
||
}
|
||
|
||
// 请求通知权限
|
||
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||
let finalStatus = existingStatus;
|
||
|
||
if (existingStatus !== 'granted') {
|
||
const { status } = await Notifications.requestPermissionsAsync();
|
||
finalStatus = status;
|
||
}
|
||
|
||
if (finalStatus !== 'granted') {
|
||
console.warn('推送通知权限未授予');
|
||
return;
|
||
}
|
||
|
||
// 设置通知监听器
|
||
this.setupNotificationListeners();
|
||
|
||
// 检查已存在的通知
|
||
await this.getAllScheduledNotifications();
|
||
|
||
this.isInitialized = true;
|
||
console.log('推送通知服务初始化成功');
|
||
} catch (error) {
|
||
console.error('推送通知服务初始化失败:', error);
|
||
} finally {
|
||
this.isIniting = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设置通知监听器
|
||
*/
|
||
private setupNotificationListeners(): void {
|
||
// 监听通知接收
|
||
Notifications.addNotificationReceivedListener((notification) => {
|
||
console.log('收到通知:', notification);
|
||
});
|
||
|
||
// 监听通知点击
|
||
Notifications.addNotificationResponseReceivedListener((response) => {
|
||
console.log('用户点击了通知:', response);
|
||
// 这里可以处理通知点击后的逻辑
|
||
this.handleNotificationResponse(response);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 处理通知响应
|
||
*/
|
||
private handleNotificationResponse(response: Notifications.NotificationResponse): void {
|
||
const { notification } = response;
|
||
const data = notification.request.content.data;
|
||
|
||
console.log('处理通知点击:', data);
|
||
|
||
// 根据通知类型处理不同的逻辑
|
||
if (data?.type === 'workout_reminder') {
|
||
// 处理运动提醒
|
||
console.log('用户点击了运动提醒通知');
|
||
} else if (data?.type === 'goal_achievement') {
|
||
// 处理目标达成通知
|
||
console.log('用户点击了目标达成通知');
|
||
} else if (data?.type === 'mood_checkin') {
|
||
// 处理心情打卡提醒
|
||
console.log('用户点击了心情打卡提醒');
|
||
} else if (data?.type === 'goal_reminder') {
|
||
// 处理目标提醒通知
|
||
console.log('用户点击了目标提醒通知', data);
|
||
// 这里可以添加导航到目标页面的逻辑
|
||
} else if (data?.type === 'lunch_reminder') {
|
||
// 处理午餐提醒通知
|
||
console.log('用户点击了午餐提醒通知', data);
|
||
// 跳转到营养记录页面
|
||
if (data?.url) {
|
||
router.push(data.url as any);
|
||
}
|
||
} else if (data?.type === 'dinner_reminder') {
|
||
// 处理晚餐提醒通知
|
||
console.log('用户点击了晚餐提醒通知', data);
|
||
// 跳转到营养记录页面
|
||
if (data?.url) {
|
||
router.push(data.url as any);
|
||
}
|
||
} else if (data?.type === NotificationTypes.CHALLENGE_ENCOURAGEMENT) {
|
||
console.log('用户点击了挑战提醒通知', data);
|
||
const targetUrl = (data?.url as string) || '/(tabs)/challenges';
|
||
router.push(targetUrl as any);
|
||
} else if (data?.type === 'mood_reminder') {
|
||
// 处理心情提醒通知
|
||
console.log('用户点击了心情提醒通知', data);
|
||
// 跳转到心情页面
|
||
if (data?.url) {
|
||
router.push(data.url as any);
|
||
}
|
||
} else if (data?.type === 'water_reminder' || data?.type === 'regular_water_reminder') {
|
||
// 处理喝水提醒通知
|
||
console.log('用户点击了喝水提醒通知', data);
|
||
// 跳转到统计页面查看喝水进度
|
||
if (data?.url) {
|
||
router.push(data.url as any);
|
||
}
|
||
} else if (data?.type === NotificationTypes.FASTING_START || data?.type === NotificationTypes.FASTING_END) {
|
||
router.push(ROUTES.TAB_FASTING as any);
|
||
} else if (data?.type === NotificationTypes.WORKOUT_COMPLETION) {
|
||
// 处理锻炼完成通知
|
||
console.log('用户点击了锻炼完成通知', data);
|
||
// 跳转到锻炼历史页面
|
||
router.push('/workout/history' as any);
|
||
} else if (data?.type === NotificationTypes.MEDICATION_REMINDER) {
|
||
// 处理药品提醒通知
|
||
console.log('用户点击了药品提醒通知', data);
|
||
// 跳转到药品页面
|
||
router.push('/(tabs)/medications' as any);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查用户是否允许推送通知(不包括系统权限检查,仅检查用户偏好)
|
||
*/
|
||
private async isNotificationAllowed(): Promise<boolean> {
|
||
try {
|
||
// 检查用户偏好设置中的推送开关
|
||
const userPreferenceEnabled = await getNotificationEnabled();
|
||
if (!userPreferenceEnabled) {
|
||
console.log('用户已在偏好设置中关闭推送通知');
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.error('检查推送权限失败:', error);
|
||
// 如果检查失败,默认允许(避免阻塞重要的健康提醒)
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 完整的权限检查(包括系统权限和用户偏好)
|
||
*/
|
||
private async hasFullNotificationPermission(): Promise<boolean> {
|
||
try {
|
||
// 检查系统权限
|
||
const permissionStatus = await this.getPermissionStatus();
|
||
if (permissionStatus !== 'granted') {
|
||
console.log('系统推送权限未授予:', permissionStatus);
|
||
return false;
|
||
}
|
||
|
||
// 检查用户偏好
|
||
const userPreferenceEnabled = await this.isNotificationAllowed();
|
||
if (!userPreferenceEnabled) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.error('完整权限检查失败:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送本地推送通知
|
||
*/
|
||
async scheduleLocalNotification(
|
||
notification: NotificationData,
|
||
trigger?: Notifications.NotificationTriggerInput
|
||
): Promise<string> {
|
||
try {
|
||
// 检查完整权限(系统权限 + 用户偏好)
|
||
const hasPermission = await this.hasFullNotificationPermission();
|
||
if (!hasPermission) {
|
||
console.log('推送通知被系统权限或用户偏好设置阻止,跳过发送');
|
||
return 'blocked_by_permission_or_preference';
|
||
}
|
||
|
||
const notificationId = await Notifications.scheduleNotificationAsync({
|
||
content: {
|
||
title: notification.title,
|
||
body: notification.body,
|
||
data: notification.data || {},
|
||
sound: notification.sound ? 'default' : undefined,
|
||
priority: notification.priority || 'default',
|
||
vibrate: notification.vibrate,
|
||
},
|
||
trigger: trigger || null, // null表示立即发送
|
||
});
|
||
|
||
console.log('✅ 本地通知已安排,ID:', notificationId);
|
||
return notificationId;
|
||
} catch (error) {
|
||
console.error('❌ 安排本地通知失败:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送立即通知
|
||
*/
|
||
async sendImmediateNotification(notification: NotificationData): Promise<string> {
|
||
console.log('📱 准备发送立即通知:', notification.title);
|
||
return this.scheduleLocalNotification(notification);
|
||
}
|
||
|
||
/**
|
||
* 安排定时通知
|
||
*/
|
||
async scheduleNotificationAtDate(
|
||
notification: NotificationData,
|
||
date: Date
|
||
): Promise<string> {
|
||
try {
|
||
// 检查完整权限(系统权限 + 用户偏好)
|
||
const hasPermission = await this.hasFullNotificationPermission();
|
||
if (!hasPermission) {
|
||
console.log('⚠️ 定时通知被系统权限或用户偏好设置阻止,跳过安排');
|
||
return 'blocked_by_permission_or_preference';
|
||
}
|
||
|
||
const notificationId = await Notifications.scheduleNotificationAsync({
|
||
content: {
|
||
title: notification.title,
|
||
body: notification.body,
|
||
data: notification.data || {},
|
||
sound: notification.sound ? 'default' : undefined,
|
||
priority: notification.priority || 'default',
|
||
vibrate: notification.vibrate,
|
||
},
|
||
trigger: {
|
||
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
||
date: date.getTime(),
|
||
} as DateTrigger,
|
||
});
|
||
|
||
console.log('✅ 定时通知已安排,ID:', notificationId, '时间:', date.toLocaleString('zh-CN'));
|
||
return notificationId;
|
||
} catch (error) {
|
||
console.error('❌ 安排定时通知失败:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 安排重复通知(仅支持秒级别)
|
||
*/
|
||
async scheduleRepeatingNotification(
|
||
notification: NotificationData,
|
||
interval: {
|
||
seconds?: number;
|
||
minutes?: number;
|
||
hours?: number;
|
||
days?: number;
|
||
weeks?: number;
|
||
months?: number;
|
||
years?: number;
|
||
}
|
||
): Promise<string> {
|
||
try {
|
||
// 计算总秒数
|
||
const totalSeconds =
|
||
(interval.seconds || 0) +
|
||
(interval.minutes || 0) * 60 +
|
||
(interval.hours || 0) * 3600 +
|
||
(interval.days || 0) * 86400 +
|
||
(interval.weeks || 0) * 604800 +
|
||
(interval.months || 0) * 2592000 +
|
||
(interval.years || 0) * 31536000;
|
||
|
||
if (totalSeconds <= 0) {
|
||
throw new Error('重复间隔必须大于0');
|
||
}
|
||
|
||
const notificationId = await Notifications.scheduleNotificationAsync({
|
||
content: {
|
||
title: notification.title,
|
||
body: notification.body,
|
||
data: notification.data || {},
|
||
sound: notification.sound ? 'default' : undefined,
|
||
priority: notification.priority || 'default',
|
||
vibrate: notification.vibrate,
|
||
},
|
||
trigger: {
|
||
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
||
seconds: totalSeconds,
|
||
repeats: true,
|
||
} as RepeatingTrigger,
|
||
});
|
||
|
||
console.log('重复通知已安排,ID:', notificationId);
|
||
return notificationId;
|
||
} catch (error) {
|
||
console.error('安排重复通知失败:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 安排日历重复通知(支持每日、每周、每月)
|
||
*/
|
||
async scheduleCalendarRepeatingNotification(
|
||
notification: NotificationData,
|
||
options: {
|
||
type: Notifications.SchedulableTriggerInputTypes;
|
||
hour: number;
|
||
minute: number;
|
||
weekdays?: number[]; // 0-6,0为周日,仅用于weekly类型
|
||
dayOfMonth?: number; // 1-31,仅用于monthly类型
|
||
}
|
||
): Promise<string> {
|
||
try {
|
||
let trigger: CalendarTrigger;
|
||
|
||
switch (options.type) {
|
||
case Notifications.SchedulableTriggerInputTypes.DAILY:
|
||
trigger = {
|
||
type: Notifications.SchedulableTriggerInputTypes.DAILY,
|
||
hour: options.hour,
|
||
minute: options.minute,
|
||
};
|
||
break;
|
||
case Notifications.SchedulableTriggerInputTypes.WEEKLY:
|
||
if (options.weekdays && options.weekdays.length > 0) {
|
||
trigger = {
|
||
type: Notifications.SchedulableTriggerInputTypes.WEEKLY,
|
||
hour: options.hour,
|
||
minute: options.minute,
|
||
weekday: options.weekdays[0], // Expo只支持单个weekday
|
||
};
|
||
} else {
|
||
trigger = {
|
||
type: Notifications.SchedulableTriggerInputTypes.DAILY,
|
||
hour: options.hour,
|
||
minute: options.minute,
|
||
};
|
||
}
|
||
break;
|
||
case Notifications.SchedulableTriggerInputTypes.MONTHLY:
|
||
trigger = {
|
||
type: Notifications.SchedulableTriggerInputTypes.MONTHLY,
|
||
hour: options.hour,
|
||
minute: options.minute,
|
||
day: options.dayOfMonth || 1,
|
||
};
|
||
break;
|
||
default:
|
||
throw new Error('不支持的重复类型');
|
||
}
|
||
|
||
const notificationId = await Notifications.scheduleNotificationAsync({
|
||
content: {
|
||
title: notification.title,
|
||
body: notification.body,
|
||
data: notification.data || {},
|
||
sound: notification.sound ? 'default' : undefined,
|
||
priority: notification.priority || 'default',
|
||
vibrate: notification.vibrate,
|
||
},
|
||
trigger,
|
||
});
|
||
|
||
console.log(`${options.type}重复通知已安排,ID:`, notificationId);
|
||
return notificationId;
|
||
} catch (error) {
|
||
console.error('安排日历重复通知失败:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消特定通知
|
||
*/
|
||
async cancelNotification(notificationId: string): Promise<void> {
|
||
try {
|
||
await Notifications.cancelScheduledNotificationAsync(notificationId);
|
||
console.log('通知已取消:', notificationId);
|
||
} catch (error) {
|
||
console.error('取消通知失败:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消所有通知
|
||
*/
|
||
async cancelAllNotifications(): Promise<void> {
|
||
try {
|
||
await Notifications.cancelAllScheduledNotificationsAsync();
|
||
console.log('所有通知已取消');
|
||
} catch (error) {
|
||
console.error('取消所有通知失败:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取所有已安排的通知
|
||
*/
|
||
async getAllScheduledNotifications(): Promise<Notifications.NotificationRequest[]> {
|
||
try {
|
||
const notifications = await Notifications.getAllScheduledNotificationsAsync();
|
||
|
||
return notifications;
|
||
} catch (error) {
|
||
console.error('获取已安排通知失败:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* 获取通知权限状态
|
||
*/
|
||
async getPermissionStatus(): Promise<Notifications.PermissionStatus> {
|
||
try {
|
||
const { status } = await Notifications.getPermissionsAsync();
|
||
return status;
|
||
} catch (error) {
|
||
console.error('获取通知权限状态失败:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 请求通知权限
|
||
*/
|
||
async requestPermission(): Promise<Notifications.PermissionStatus> {
|
||
try {
|
||
const { status } = await Notifications.requestPermissionsAsync();
|
||
return status;
|
||
} catch (error) {
|
||
console.error('请求通知权限失败:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 导出单例实例
|
||
export const notificationService = NotificationService.getInstance();
|
||
|
||
// 预定义的推送通知类型
|
||
export const NotificationTypes = {
|
||
WORKOUT_REMINDER: 'workout_reminder',
|
||
MOOD_CHECKIN: 'mood_checkin',
|
||
NUTRITION_REMINDER: 'nutrition_reminder',
|
||
PROGRESS_UPDATE: 'progress_update',
|
||
LUNCH_REMINDER: 'lunch_reminder',
|
||
DINNER_REMINDER: 'dinner_reminder',
|
||
MOOD_REMINDER: 'mood_reminder',
|
||
WATER_REMINDER: 'water_reminder',
|
||
REGULAR_WATER_REMINDER: 'regular_water_reminder',
|
||
CHALLENGE_ENCOURAGEMENT: 'challenge_encouragement',
|
||
WORKOUT_COMPLETION: 'workout_completion',
|
||
FASTING_START: 'fasting_start',
|
||
FASTING_END: 'fasting_end',
|
||
MEDICATION_REMINDER: 'medication_reminder',
|
||
} as const;
|
||
|
||
// 便捷方法
|
||
export const sendWorkoutReminder = (title: string, body: string, date?: Date) => {
|
||
const notification: NotificationData = {
|
||
title,
|
||
body,
|
||
data: { type: NotificationTypes.WORKOUT_REMINDER },
|
||
sound: true,
|
||
priority: 'high',
|
||
};
|
||
|
||
if (date) {
|
||
return notificationService.scheduleNotificationAtDate(notification, date);
|
||
} else {
|
||
return notificationService.sendImmediateNotification(notification);
|
||
}
|
||
};
|
||
|
||
// sendGoalAchievement 函数已删除,因为目标功能已移除
|
||
|
||
export const sendMoodCheckinReminder = (title: string, body: string, date?: Date) => {
|
||
const notification: NotificationData = {
|
||
title,
|
||
body,
|
||
data: { type: NotificationTypes.MOOD_CHECKIN },
|
||
sound: true,
|
||
priority: 'normal',
|
||
};
|
||
|
||
if (date) {
|
||
return notificationService.scheduleNotificationAtDate(notification, date);
|
||
} else {
|
||
return notificationService.sendImmediateNotification(notification);
|
||
}
|
||
};
|
||
|
||
export const sendMedicationReminder = (title: string, body: string, medicationId?: string, date?: Date) => {
|
||
const notification: NotificationData = {
|
||
title,
|
||
body,
|
||
data: {
|
||
type: NotificationTypes.MEDICATION_REMINDER,
|
||
medicationId: medicationId || ''
|
||
},
|
||
sound: true,
|
||
priority: 'high',
|
||
};
|
||
|
||
if (date) {
|
||
return notificationService.scheduleNotificationAtDate(notification, date);
|
||
} else {
|
||
return notificationService.sendImmediateNotification(notification);
|
||
}
|
||
};
|