Files
digital-pilates/store/challengeSlice.ts
richarjiang 5f05abc3d5 feat: 添加挑战页面和相关功能
- 在布局中新增挑战页面的导航
- 在首页中添加挑战计划卡片,支持用户点击跳转
- 更新登录页面的标题样式,调整字体粗细
- 集成 Redux 状态管理,新增挑战相关的 reducer
2025-08-12 22:54:23 +08:00

130 lines
3.9 KiB
TypeScript

import { buildDefaultCustomFromPlan, DayPlan, ExerciseCustomConfig, generatePilates30DayPlan, PilatesLevel } from '@/utils/pilatesPlan';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
export type DayStatus = 'locked' | 'available' | 'completed';
export type ChallengeDayState = {
plan: DayPlan;
status: DayStatus;
completedAt?: string | null; // ISO
notes?: string;
custom?: ExerciseCustomConfig[]; // 用户自定义:启用/禁用、组数、时长
};
export type ChallengeState = {
startedAt?: string | null;
level: PilatesLevel;
days: ChallengeDayState[]; // 1..30
streak: number; // 连续天数
};
const STORAGE_KEY = '@pilates_challenge_30d';
const initialState: ChallengeState = {
startedAt: null,
level: 'beginner',
days: [],
streak: 0,
};
function computeStreak(days: ChallengeDayState[]): number {
// 连续从第1天开始的已完成天数
let s = 0;
for (let i = 0; i < days.length; i += 1) {
if (days[i].status === 'completed') s += 1; else break;
}
return s;
}
export const initChallenge = createAsyncThunk(
'challenge/init',
async (_: void, { getState }) => {
const persisted = await AsyncStorage.getItem(STORAGE_KEY);
if (persisted) {
try {
const parsed = JSON.parse(persisted) as ChallengeState;
return parsed;
} catch {}
}
// 默认生成
const level: PilatesLevel = 'beginner';
const plans = generatePilates30DayPlan(level);
const days: ChallengeDayState[] = plans.map((p, idx) => ({
plan: p,
status: idx === 0 ? 'available' : 'locked',
custom: buildDefaultCustomFromPlan(p),
}));
const state: ChallengeState = {
startedAt: new Date().toISOString(),
level,
days,
streak: 0,
};
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state));
return state;
}
);
export const persistChallenge = createAsyncThunk(
'challenge/persist',
async (_: void, { getState }) => {
const s = (getState() as any).challenge as ChallengeState;
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(s));
return true;
}
);
export const completeDay = createAsyncThunk(
'challenge/completeDay',
async (dayNumber: number, { getState, dispatch }) => {
const state = (getState() as any).challenge as ChallengeState;
const idx = dayNumber - 1;
const days = [...state.days];
if (!days[idx] || days[idx].status === 'completed') return state;
days[idx] = { ...days[idx], status: 'completed', completedAt: new Date().toISOString() };
if (days[idx + 1]) {
days[idx + 1] = { ...days[idx + 1], status: 'available' };
}
const next: ChallengeState = {
...state,
days,
streak: computeStreak(days),
};
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
return next;
}
);
const challengeSlice = createSlice({
name: 'challenge',
initialState,
reducers: {
setLevel(state, action: PayloadAction<PilatesLevel>) {
state.level = action.payload;
},
setNote(state, action: PayloadAction<{ dayNumber: number; notes: string }>) {
const idx = action.payload.dayNumber - 1;
if (state.days[idx]) state.days[idx].notes = action.payload.notes;
},
setCustom(state, action: PayloadAction<{ dayNumber: number; custom: ExerciseCustomConfig[] }>) {
const idx = action.payload.dayNumber - 1;
if (state.days[idx]) state.days[idx].custom = action.payload.custom;
},
},
extraReducers: (builder) => {
builder
.addCase(initChallenge.fulfilled, (_state, action) => {
return action.payload as ChallengeState;
})
.addCase(completeDay.fulfilled, (_state, action) => {
return action.payload as ChallengeState;
});
},
});
export const { setLevel, setNote, setCustom } = challengeSlice.actions;
export default challengeSlice.reducer;