实现完整的周期性断食计划系统,支持每日自动续订和通知管理: - 新增周期性断食状态管理(activeCycle、currentCycleSession、cycleHistory) - 实现周期性断食会话的自动完成和续订逻辑 - 添加独立的周期性断食通知系统,避免与单次断食通知冲突 - 支持暂停/恢复周期性断食计划 - 添加周期性断食数据持久化和水合功能 - 优化断食界面,优先显示周期性断食信息 - 新增空状态引导界面,提升用户体验 - 保持单次断食功能向后兼容
408 lines
12 KiB
TypeScript
408 lines
12 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);
|
|
}
|
|
};
|
|
|
|
// 周期性断食相关的存储函数
|
|
export const loadActiveFastingCycle = async (): Promise<any | null> => {
|
|
try {
|
|
const stored = await AsyncStorage.getItem('@fasting_active_cycle');
|
|
if (!stored) return null;
|
|
|
|
const parsed = JSON.parse(stored);
|
|
return parsed;
|
|
} catch (error) {
|
|
console.warn('读取周期性断食计划失败', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const saveActiveFastingCycle = async (cycle: any | null): Promise<void> => {
|
|
try {
|
|
if (cycle) {
|
|
await AsyncStorage.setItem('@fasting_active_cycle', JSON.stringify(cycle));
|
|
} else {
|
|
await AsyncStorage.removeItem('@fasting_active_cycle');
|
|
}
|
|
} catch (error) {
|
|
console.error('保存周期性断食计划失败', error);
|
|
throw new Error('保存周期性断食计划失败,请稍后重试');
|
|
}
|
|
};
|
|
|
|
export const loadCurrentCycleSession = async (): Promise<any | null> => {
|
|
try {
|
|
const stored = await AsyncStorage.getItem('@fasting_current_cycle_session');
|
|
if (!stored) return null;
|
|
|
|
const parsed = JSON.parse(stored);
|
|
return parsed;
|
|
} catch (error) {
|
|
console.warn('读取当前断食会话失败', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const saveCurrentCycleSession = async (session: any | null): Promise<void> => {
|
|
try {
|
|
if (session) {
|
|
await AsyncStorage.setItem('@fasting_current_cycle_session', JSON.stringify(session));
|
|
} else {
|
|
await AsyncStorage.removeItem('@fasting_current_cycle_session');
|
|
}
|
|
} catch (error) {
|
|
console.error('保存当前断食会话失败', error);
|
|
throw new Error('保存断食会话失败,请稍后重试');
|
|
}
|
|
};
|
|
|
|
export const loadCycleHistory = async (): Promise<any[]> => {
|
|
try {
|
|
const stored = await AsyncStorage.getItem('@fasting_cycle_history');
|
|
if (!stored) return [];
|
|
|
|
const parsed = JSON.parse(stored);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch (error) {
|
|
console.warn('读取断食周期历史失败', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
export const saveCycleHistory = async (history: any[]): Promise<void> => {
|
|
try {
|
|
await AsyncStorage.setItem('@fasting_cycle_history', JSON.stringify(history));
|
|
} catch (error) {
|
|
console.error('保存断食周期历史失败', error);
|
|
throw new Error('保存断食历史失败,请稍后重试');
|
|
}
|
|
};
|
|
|
|
// 计算下一个断食周期的开始时间
|
|
export const calculateNextCycleStart = (
|
|
cycle: { startHour: number; startMinute: number },
|
|
baseDate: Date = new Date()
|
|
): Date => {
|
|
const now = dayjs(baseDate);
|
|
const today = now.startOf('day').hour(cycle.startHour).minute(cycle.startMinute).second(0).millisecond(0);
|
|
|
|
// 如果今天的开始时间已过,则从明天开始
|
|
if (today.isBefore(now)) {
|
|
return today.add(1, 'day').toDate();
|
|
}
|
|
|
|
return today.toDate();
|
|
};
|
|
|
|
// 获取周期性断食的统计信息
|
|
export const getCycleStats = (history: any[]) => {
|
|
const completedCycles = history.filter(session => session.completed);
|
|
const totalCycles = history.length;
|
|
const currentStreak = calculateCurrentStreak(history);
|
|
const longestStreak = calculateLongestStreak(history);
|
|
|
|
return {
|
|
totalCycles,
|
|
completedCycles: completedCycles.length,
|
|
completionRate: totalCycles > 0 ? (completedCycles.length / totalCycles) * 100 : 0,
|
|
currentStreak,
|
|
longestStreak,
|
|
};
|
|
};
|
|
|
|
// 计算当前连续完成天数
|
|
const calculateCurrentStreak = (history: any[]): number => {
|
|
if (history.length === 0) return 0;
|
|
|
|
let streak = 0;
|
|
const today = dayjs().startOf('day');
|
|
|
|
for (let i = 0; i < history.length; i++) {
|
|
const session = history[i];
|
|
if (!session.completed) break;
|
|
|
|
const sessionDate = dayjs(session.cycleDate);
|
|
const expectedDate = today.subtract(i, 'day');
|
|
|
|
if (sessionDate.isSame(expectedDate, 'day')) {
|
|
streak++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return streak;
|
|
};
|
|
|
|
// 计算最长连续完成天数
|
|
const calculateLongestStreak = (history: any[]): number => {
|
|
if (history.length === 0) return 0;
|
|
|
|
let longestStreak = 0;
|
|
let currentStreak = 0;
|
|
|
|
for (const session of history) {
|
|
if (session.completed) {
|
|
currentStreak++;
|
|
longestStreak = Math.max(longestStreak, currentStreak);
|
|
} else {
|
|
currentStreak = 0;
|
|
}
|
|
}
|
|
|
|
return longestStreak;
|
|
};
|