feat: 添加训练计划和打卡功能

- 新增训练计划页面,允许用户制定个性化的训练计划
- 集成打卡功能,用户可以记录每日的训练情况
- 更新 Redux 状态管理,添加训练计划相关的 reducer
- 在首页中添加训练计划卡片,支持用户点击跳转
- 更新样式和布局,以适应新功能的展示和交互
- 添加日期选择器和相关依赖,支持用户选择训练日期
This commit is contained in:
richarjiang
2025-08-13 09:10:00 +08:00
parent e0e000b64f
commit f3e6250505
24 changed files with 1898 additions and 609 deletions

78
store/checkinSlice.ts Normal file
View File

@@ -0,0 +1,78 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export type CheckinExercise = {
key: string;
name: string;
category: string;
sets: number; // 组数
reps?: number; // 每组重复(计次型)
durationSec?: number; // 每组时长(计时型)
completed?: boolean; // 是否已完成该动作
};
export type CheckinRecord = {
id: string;
date: string; // YYYY-MM-DD
items: CheckinExercise[];
note?: string;
};
export type CheckinState = {
byDate: Record<string, CheckinRecord>;
currentDate: string | null;
};
const initialState: CheckinState = {
byDate: {},
currentDate: null,
};
function ensureRecord(state: CheckinState, date: string): CheckinRecord {
if (!state.byDate[date]) {
state.byDate[date] = {
id: `rec_${date}`,
date,
items: [],
};
}
return state.byDate[date];
}
const checkinSlice = createSlice({
name: 'checkin',
initialState,
reducers: {
setCurrentDate(state, action: PayloadAction<string>) {
state.currentDate = action.payload; // 期望格式 YYYY-MM-DD
ensureRecord(state, action.payload);
},
addExercise(state, action: PayloadAction<{ date: string; item: CheckinExercise }>) {
const rec = ensureRecord(state, action.payload.date);
// 若同 key 已存在则覆盖参数(更接近用户“重新选择/编辑”的心智)
const idx = rec.items.findIndex((it) => it.key === action.payload.item.key);
const normalized: CheckinExercise = { ...action.payload.item, completed: false };
if (idx >= 0) rec.items[idx] = normalized; else rec.items.push(normalized);
},
removeExercise(state, action: PayloadAction<{ date: string; key: string }>) {
const rec = ensureRecord(state, action.payload.date);
rec.items = rec.items.filter((it) => it.key !== action.payload.key);
},
toggleExerciseCompleted(state, action: PayloadAction<{ date: string; key: string }>) {
const rec = ensureRecord(state, action.payload.date);
const idx = rec.items.findIndex((it) => it.key === action.payload.key);
if (idx >= 0) rec.items[idx].completed = !rec.items[idx].completed;
},
setNote(state, action: PayloadAction<{ date: string; note: string }>) {
const rec = ensureRecord(state, action.payload.date);
rec.note = action.payload.note;
},
resetDate(state, action: PayloadAction<string>) {
delete state.byDate[action.payload];
},
},
});
export const { setCurrentDate, addExercise, removeExercise, toggleExerciseCompleted, setNote, resetDate } = checkinSlice.actions;
export default checkinSlice.reducer;

View File

@@ -1,11 +1,15 @@
import { configureStore } from '@reduxjs/toolkit';
import challengeReducer from './challengeSlice';
import checkinReducer from './checkinSlice';
import trainingPlanReducer from './trainingPlanSlice';
import userReducer from './userSlice';
export const store = configureStore({
reducer: {
user: userReducer,
challenge: challengeReducer,
checkin: checkinReducer,
trainingPlan: trainingPlanReducer,
},
// React Native 环境默认即可
});

148
store/trainingPlanSlice.ts Normal file
View File

@@ -0,0 +1,148 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
export type PlanMode = 'daysOfWeek' | 'sessionsPerWeek';
export type PlanGoal =
| 'postpartum_recovery' // 产后恢复
| 'fat_loss' // 减脂塑形
| 'posture_correction' // 体态矫正
| 'core_strength' // 核心力量
| 'flexibility' // 柔韧灵活
| 'rehab' // 康复保健
| 'stress_relief'; // 释压放松
export type TrainingPlan = {
id: string;
createdAt: string; // ISO
startDate: string; // ISO (当天或下周一)
mode: PlanMode;
daysOfWeek: number[]; // 0(日) - 6(六)
sessionsPerWeek: number; // 1..7
goal: PlanGoal | '';
startWeightKg?: number;
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
};
export type TrainingPlanState = {
current?: TrainingPlan | null;
draft: Omit<TrainingPlan, 'id' | 'createdAt'>;
};
const STORAGE_KEY = '@training_plan';
function nextMondayISO(): string {
const now = new Date();
const day = now.getDay();
const diff = (8 - day) % 7 || 7; // 距下周一的天数
const next = new Date(now);
next.setDate(now.getDate() + diff);
next.setHours(0, 0, 0, 0);
return next.toISOString();
}
const initialState: TrainingPlanState = {
current: null,
draft: {
startDate: new Date(new Date().setHours(0, 0, 0, 0)).toISOString(),
mode: 'daysOfWeek',
daysOfWeek: [1, 3, 5],
sessionsPerWeek: 3,
goal: '',
startWeightKg: undefined,
preferredTimeOfDay: '',
},
};
export const loadTrainingPlan = createAsyncThunk('trainingPlan/load', async () => {
const str = await AsyncStorage.getItem(STORAGE_KEY);
if (!str) return null;
try {
return JSON.parse(str) as TrainingPlan;
} catch {
return null;
}
});
export const saveTrainingPlan = createAsyncThunk(
'trainingPlan/save',
async (_: void, { getState }) => {
const s = (getState() as any).trainingPlan as TrainingPlanState;
const draft = s.draft;
const plan: TrainingPlan = {
id: `plan_${Date.now()}`,
createdAt: new Date().toISOString(),
...draft,
};
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(plan));
return plan;
}
);
const trainingPlanSlice = createSlice({
name: 'trainingPlan',
initialState,
reducers: {
setMode(state, action: PayloadAction<PlanMode>) {
state.draft.mode = action.payload;
},
toggleDayOfWeek(state, action: PayloadAction<number>) {
const d = action.payload;
const set = new Set(state.draft.daysOfWeek);
if (set.has(d)) set.delete(d); else set.add(d);
state.draft.daysOfWeek = Array.from(set).sort();
},
setSessionsPerWeek(state, action: PayloadAction<number>) {
const n = Math.min(7, Math.max(1, action.payload));
state.draft.sessionsPerWeek = n;
},
setGoal(state, action: PayloadAction<TrainingPlan['goal']>) {
state.draft.goal = action.payload;
},
setStartWeight(state, action: PayloadAction<number | undefined>) {
state.draft.startWeightKg = action.payload;
},
setStartDate(state, action: PayloadAction<string>) {
state.draft.startDate = action.payload;
},
setPreferredTime(state, action: PayloadAction<TrainingPlan['preferredTimeOfDay']>) {
state.draft.preferredTimeOfDay = action.payload;
},
setStartDateNextMonday(state) {
state.draft.startDate = nextMondayISO();
},
resetDraft(state) {
state.draft = initialState.draft;
},
},
extraReducers: (builder) => {
builder
.addCase(loadTrainingPlan.fulfilled, (state, action) => {
state.current = action.payload;
// 若存在历史计划,初始化 draft 基于该计划(便于编辑)
if (action.payload) {
const { id, createdAt, ...rest } = action.payload;
state.draft = { ...rest };
}
})
.addCase(saveTrainingPlan.fulfilled, (state, action) => {
state.current = action.payload;
});
},
});
export const {
setMode,
toggleDayOfWeek,
setSessionsPerWeek,
setGoal,
setStartWeight,
setStartDate,
setPreferredTime,
setStartDateNextMonday,
resetDraft,
} = trainingPlanSlice.actions;
export default trainingPlanSlice.reducer;