feat: 添加挑战页面和相关功能
- 在布局中新增挑战页面的导航 - 在首页中添加挑战计划卡片,支持用户点击跳转 - 更新登录页面的标题样式,调整字体粗细 - 集成 Redux 状态管理,新增挑战相关的 reducer
This commit is contained in:
129
store/challengeSlice.ts
Normal file
129
store/challengeSlice.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user