- 在布局中新增挑战页面的导航 - 在首页中添加挑战计划卡片,支持用户点击跳转 - 更新登录页面的标题样式,调整字体粗细 - 集成 Redux 状态管理,新增挑战相关的 reducer
130 lines
3.9 KiB
TypeScript
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;
|
|
|
|
|