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

258 lines
7.7 KiB
TypeScript

import { FASTING_STORAGE_KEYS } from '@/constants/Fasting';
import type { FastingSchedule } from '@/store/fastingSlice';
import AsyncStorage from '@/utils/kvStore';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
dayjs.extend(duration);
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
export type FastingPhase = 'upcoming' | 'fasting' | 'completed';
export const calculateFastingWindow = (start: Date, fastingHours: number) => {
const startDate = dayjs(start).second(0).millisecond(0);
const endDate = startDate.add(fastingHours, 'hour');
return {
start: startDate.toDate(),
end: endDate.toDate(),
};
};
export const getFastingPhase = (start?: Date | null, end?: Date | null, now: Date = new Date()): FastingPhase => {
if (!start || !end) {
return 'completed';
}
const nowJs = dayjs(now);
const startJs = dayjs(start);
const endJs = dayjs(end);
if (nowJs.isBefore(startJs)) {
return 'upcoming';
}
if (nowJs.isSameOrAfter(startJs) && nowJs.isBefore(endJs)) {
return 'fasting';
}
return 'completed';
};
export const getPhaseLabel = (phase: FastingPhase) => {
switch (phase) {
case 'fasting':
return '断食中';
case 'upcoming':
return '可饮食';
case 'completed':
return '可饮食';
default:
return '可饮食';
}
};
export const formatDayDescriptor = (date: Date | null | undefined, now: Date = new Date()) => {
if (!date) return '--';
const target = dayjs(date);
const today = dayjs(now).startOf('day');
if (target.isSame(today, 'day')) {
return '今天';
}
if (target.isSame(today.subtract(1, 'day'), 'day')) {
return '昨天';
}
if (target.isSame(today.add(1, 'day'), 'day')) {
return '明天';
}
return target.format('MM-DD');
};
export const formatTime = (date: Date | null | undefined) => {
if (!date) return '--:--';
return dayjs(date).format('HH:mm');
};
export const formatCountdown = (target: Date | null | undefined, now: Date = new Date()) => {
if (!target) return '--:--:--';
const diff = dayjs(target).diff(now);
if (diff <= 0) {
return '00:00:00';
}
const dur = dayjs.duration(diff);
const hours = String(Math.floor(dur.asHours())).padStart(2, '0');
const minutes = String(dur.minutes()).padStart(2, '0');
const seconds = String(dur.seconds()).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
};
export const buildDisplayWindow = (start?: Date | null, end?: Date | null) => {
return {
startDayLabel: formatDayDescriptor(start ?? null),
startTimeLabel: formatTime(start ?? null),
endDayLabel: formatDayDescriptor(end ?? null),
endTimeLabel: formatTime(end ?? null),
};
};
export const loadActiveFastingSchedule = async (): Promise<FastingSchedule | null> => {
try {
const stored = await AsyncStorage.getItem(FASTING_STORAGE_KEYS.activeSchedule);
if (!stored) return null;
const parsed = JSON.parse(stored) as Partial<FastingSchedule>;
if (
!parsed ||
typeof parsed.planId !== 'string' ||
typeof parsed.startISO !== 'string' ||
typeof parsed.endISO !== 'string'
) {
return null;
}
return {
planId: parsed.planId,
startISO: parsed.startISO,
endISO: parsed.endISO,
createdAtISO: parsed.createdAtISO ?? parsed.startISO,
updatedAtISO: parsed.updatedAtISO ?? parsed.endISO,
origin: parsed.origin ?? 'manual',
};
} catch (error) {
console.warn('读取断食计划失败', error);
return null;
}
};
export const persistActiveFastingSchedule = async (schedule: FastingSchedule | null) => {
try {
if (schedule) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.activeSchedule, JSON.stringify(schedule));
} else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.activeSchedule);
}
} catch (error) {
console.warn('保存断食计划失败', error);
}
};
export const loadPreferredPlanId = async (): Promise<string | null> => {
try {
return await AsyncStorage.getItem(FASTING_STORAGE_KEYS.preferredPlanId);
} catch (error) {
console.warn('加载断食首选计划ID失败', error);
return null;
}
};
export const savePreferredPlanId = async (planId: string) => {
try {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.preferredPlanId, planId);
} catch (error) {
console.warn('保存断食首选计划ID失败', error);
}
};
export type FastingNotificationIds = {
preStartId?: string | null; // 开始前30分钟
startId?: string | null; // 开始时
preEndId?: string | null; // 结束前30分钟
endId?: string | null; // 结束时
};
export const getFastingNotificationsRegistered = async (): Promise<boolean> => {
try {
const value = await AsyncStorage.getItem(FASTING_STORAGE_KEYS.notificationsRegistered);
return value === 'true';
} catch (error) {
console.warn('读取断食通知注册状态失败', error);
return false;
}
};
export const setFastingNotificationsRegistered = async (registered: boolean) => {
try {
if (registered) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.notificationsRegistered, 'true');
} else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.notificationsRegistered);
}
} catch (error) {
console.warn('更新断食通知注册状态失败', error);
}
};
export const loadStoredFastingNotificationIds = async (): Promise<FastingNotificationIds> => {
try {
const [preStartId, startId, preEndId, endId] = await Promise.all([
AsyncStorage.getItem(FASTING_STORAGE_KEYS.preStartNotificationId),
AsyncStorage.getItem(FASTING_STORAGE_KEYS.startNotificationId),
AsyncStorage.getItem(FASTING_STORAGE_KEYS.preEndNotificationId),
AsyncStorage.getItem(FASTING_STORAGE_KEYS.endNotificationId),
]);
return {
preStartId: preStartId ?? undefined,
startId: startId ?? undefined,
preEndId: preEndId ?? undefined,
endId: endId ?? undefined,
};
} catch (error) {
console.warn('读取断食通知ID失败', error);
return {};
}
};
export const saveFastingNotificationIds = async (ids: FastingNotificationIds) => {
try {
// 保存开始前30分钟通知ID
if (ids.preStartId) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.preStartNotificationId, ids.preStartId);
} else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preStartNotificationId);
}
// 保存开始时通知ID
if (ids.startId) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.startNotificationId, ids.startId);
} else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId);
}
// 保存结束前30分钟通知ID
if (ids.preEndId) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.preEndNotificationId, ids.preEndId);
} else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preEndNotificationId);
}
// 保存结束时通知ID
if (ids.endId) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.endNotificationId, ids.endId);
} else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.endNotificationId);
}
} catch (error) {
console.warn('保存断食通知ID失败', error);
}
};
export const clearFastingNotificationIds = async () => {
try {
await Promise.all([
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preStartNotificationId),
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId),
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preEndNotificationId),
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.endNotificationId),
]);
} catch (error) {
console.warn('清除断食通知ID失败', error);
}
};