feat: 更新应用版本和主题设置
- 将应用版本更新至 1.0.3,修改相关配置文件 - 强制全局使用浅色主题,确保一致的用户体验 - 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划 - 优化打卡功能,支持自动同步打卡记录至服务器 - 更新样式以适应新功能的展示和交互
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
<item name="android:statusBarColor">#ffffff</item>
|
<item name="android:statusBarColor">#ffffff</item>
|
||||||
|
<item name="android:forceDarkAllowed">false</item>
|
||||||
</style>
|
</style>
|
||||||
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
|
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
|
||||||
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
|
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
|
||||||
|
|||||||
4
app.json
4
app.json
@@ -2,11 +2,11 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "digital-pilates",
|
"name": "digital-pilates",
|
||||||
"slug": "digital-pilates",
|
"slug": "digital-pilates",
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/logo.jpeg",
|
"icon": "./assets/images/logo.jpeg",
|
||||||
"scheme": "digitalpilates",
|
"scheme": "digitalpilates",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "light",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"jsEngine": "jsc",
|
"jsEngine": "jsc",
|
||||||
"ios": {
|
"ios": {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { fetchRecommendations, RecommendationType } from '@/services/recommendat
|
|||||||
// Removed WorkoutCard import since we no longer use the horizontal carousel
|
// Removed WorkoutCard import since we no longer use the horizontal carousel
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { getChineseGreeting } from '@/utils/date';
|
import { getChineseGreeting } from '@/utils/date';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
|
import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
|
||||||
@@ -137,7 +138,6 @@ export default function HomeScreen() {
|
|||||||
try {
|
try {
|
||||||
const cards = await fetchRecommendations();
|
const cards = await fetchRecommendations();
|
||||||
|
|
||||||
console.log('fetchRecommendations', cards);
|
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
const mapped: RecommendItem[] = [];
|
const mapped: RecommendItem[] = [];
|
||||||
for (const c of cards || []) {
|
for (const c of cards || []) {
|
||||||
@@ -161,7 +161,7 @@ export default function HomeScreen() {
|
|||||||
title: c.title || '今日打卡',
|
title: c.title || '今日打卡',
|
||||||
subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持',
|
subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
onPress: () => pushIfAuthedElseLogin('/checkin'),
|
onPress: () => pushIfAuthedElseLogin('/checkin?date=' + dayjs().format('YYYY-MM-DD')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +258,7 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.featureCard, styles.featureCardTertiary]}
|
style={[styles.featureCard, styles.featureCardTertiary]}
|
||||||
onPress={() => pushIfAuthedElseLogin('/checkin')}
|
onPress={() => pushIfAuthedElseLogin('/checkin?date=' + dayjs().format('YYYY-MM-DD'))}
|
||||||
>
|
>
|
||||||
<ThemedText style={styles.featureTitle}>每日打卡</ThemedText>
|
<ThemedText style={styles.featureTitle}>每日打卡</ThemedText>
|
||||||
<ThemedText style={styles.featureSubtitle}>自选动作 · 记录完成</ThemedText>
|
<ThemedText style={styles.featureSubtitle}>自选动作 · 记录完成</ThemedText>
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ export default function PersonalScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||||
<StatusBar barStyle={theme === 'light' ? 'dark-content' : 'light-content'} backgroundColor="transparent" translucent />
|
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
|
||||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={[styles.scrollView, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}
|
style={[styles.scrollView, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||||
import { useFonts } from 'expo-font';
|
import { useFonts } from 'expo-font';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
@@ -36,7 +36,7 @@ export default function RootLayout() {
|
|||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<Bootstrapper>
|
<Bootstrapper>
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<ThemeProvider value={DefaultTheme}>
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="onboarding" />
|
<Stack.Screen name="onboarding" />
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
@@ -52,7 +52,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="dark" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</Bootstrapper>
|
</Bootstrapper>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { Colors } from '@/constants/Colors';
|
|||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import type { CheckinExercise } from '@/store/checkinSlice';
|
import type { CheckinExercise } from '@/store/checkinSlice';
|
||||||
import { getDailyCheckins, removeExercise, replaceExercises, setCurrentDate, syncCheckin, toggleExerciseCompleted } from '@/store/checkinSlice';
|
import { getDailyCheckins, removeExercise, replaceExercises, setCurrentDate, toggleExerciseCompleted } from '@/store/checkinSlice';
|
||||||
|
import { loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice';
|
||||||
import { buildClassicalSession } from '@/utils/classicalSession';
|
import { buildClassicalSession } from '@/utils/classicalSession';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
import { Alert, FlatList, Modal, SafeAreaView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
@@ -24,6 +24,7 @@ export default function CheckinHome() {
|
|||||||
const params = useLocalSearchParams<{ date?: string }>();
|
const params = useLocalSearchParams<{ date?: string }>();
|
||||||
const today = useMemo(() => formatDate(new Date()), []);
|
const today = useMemo(() => formatDate(new Date()), []);
|
||||||
const checkin = useAppSelector((s) => (s as any).checkin);
|
const checkin = useAppSelector((s) => (s as any).checkin);
|
||||||
|
const training = useAppSelector((s) => (s as any).trainingPlan);
|
||||||
const routeDateParam = typeof params?.date === 'string' && params.date ? params.date : undefined;
|
const routeDateParam = typeof params?.date === 'string' && params.date ? params.date : undefined;
|
||||||
const currentDate: string = routeDateParam || (checkin?.currentDate as string) || today;
|
const currentDate: string = routeDateParam || (checkin?.currentDate as string) || today;
|
||||||
const record = checkin?.byDate?.[currentDate] as (undefined | { items?: CheckinExercise[]; note?: string; raw?: any[] });
|
const record = checkin?.byDate?.[currentDate] as (undefined | { items?: CheckinExercise[]; note?: string; raw?: any[] });
|
||||||
@@ -52,18 +53,15 @@ export default function CheckinHome() {
|
|||||||
}
|
}
|
||||||
}, [dispatch, currentDate]);
|
}, [dispatch, currentDate]);
|
||||||
|
|
||||||
const lastSyncSigRef = useRef<string>('');
|
// 加载训练计划列表:仅在页面挂载时尝试一次,避免因失败导致的重复请求
|
||||||
useFocusEffect(
|
const hasLoadedPlansRef = useRef(false);
|
||||||
React.useCallback(() => {
|
useEffect(() => {
|
||||||
// 仅当本地条目发生变更时才上报,避免反复刷写
|
if (hasLoadedPlansRef.current) return;
|
||||||
const sig = JSON.stringify(record?.items || []);
|
hasLoadedPlansRef.current = true;
|
||||||
if (record?.items && Array.isArray(record.items) && sig !== lastSyncSigRef.current) {
|
dispatch(loadPlans());
|
||||||
lastSyncSigRef.current = sig;
|
}, [dispatch]);
|
||||||
dispatch(syncCheckin({ date: currentDate, items: record.items as CheckinExercise[], note: record?.note }));
|
|
||||||
}
|
// 同步触发逻辑改为显式操作处调用,避免页面渲染期间的副作用
|
||||||
return () => { };
|
|
||||||
}, [dispatch, currentDate, record?.items, record?.note])
|
|
||||||
);
|
|
||||||
|
|
||||||
const [genVisible, setGenVisible] = useState(false);
|
const [genVisible, setGenVisible] = useState(false);
|
||||||
const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner');
|
const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner');
|
||||||
@@ -71,11 +69,33 @@ export default function CheckinHome() {
|
|||||||
const [genWithNotes, setGenWithNotes] = useState(true);
|
const [genWithNotes, setGenWithNotes] = useState(true);
|
||||||
const [genRest, setGenRest] = useState('30');
|
const [genRest, setGenRest] = useState('30');
|
||||||
|
|
||||||
|
// 计算“进行中的训练计划”(startDate <= 当前日期)。若 currentId 存在,优先该计划。
|
||||||
|
const activePlan: TrainingPlan | null = useMemo(() => {
|
||||||
|
const plans: TrainingPlan[] = training?.plans || [];
|
||||||
|
if (!plans.length) return null;
|
||||||
|
const current = training?.currentId ? plans.find((p) => p.id === training.currentId) : null;
|
||||||
|
const dateObj = new Date(`${currentDate}T00:00:00`);
|
||||||
|
if (current && new Date(current.startDate) <= dateObj) return current;
|
||||||
|
const ongoing = plans
|
||||||
|
.filter((p) => new Date(p.startDate) <= dateObj)
|
||||||
|
.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||||
|
return ongoing[0] ?? null;
|
||||||
|
}, [training?.plans, training?.currentId, currentDate]);
|
||||||
|
|
||||||
|
const planStartText = useMemo(() => {
|
||||||
|
if (!activePlan?.startDate) return '';
|
||||||
|
const d = new Date(activePlan.startDate);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
||||||
|
const day = `${d.getDate()}`.padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}, [activePlan?.startDate]);
|
||||||
|
|
||||||
const onGenerate = () => {
|
const onGenerate = () => {
|
||||||
const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10)));
|
const restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10)));
|
||||||
const { items, note } = buildClassicalSession({ withSectionRests: genWithRests, restSeconds: restSec, withNotes: genWithNotes, level: genLevel });
|
const { items, note } = buildClassicalSession({ withSectionRests: genWithRests, restSeconds: restSec, withNotes: genWithNotes, level: genLevel });
|
||||||
dispatch(replaceExercises({ date: currentDate, items, note }));
|
dispatch(replaceExercises({ date: currentDate, items, note }));
|
||||||
dispatch(syncCheckin({ date: currentDate, items, note }));
|
// 自动同步将由中间件处理
|
||||||
setGenVisible(false);
|
setGenVisible(false);
|
||||||
Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。');
|
Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。');
|
||||||
};
|
};
|
||||||
@@ -94,6 +114,43 @@ export default function CheckinHome() {
|
|||||||
<Text style={[styles.subtitle, { color: colorTokens.textMuted }]}>请选择动作并记录完成情况</Text>
|
<Text style={[styles.subtitle, { color: colorTokens.textMuted }]}>请选择动作并记录完成情况</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 训练计划提示(非强制) */}
|
||||||
|
<View style={{ paddingHorizontal: 20, marginTop: 8 }}>
|
||||||
|
{activePlan ? (
|
||||||
|
<View style={[styles.planHintCard, { backgroundColor: colorTokens.card, borderColor: colorTokens.border }]}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.planHintTitle, { color: colorTokens.text }]}>已有训练计划进行中</Text>
|
||||||
|
{!!planStartText && (
|
||||||
|
<Text style={[styles.planHintSub, { color: colorTokens.textMuted }]}>开始于 {planStartText}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.hintBtn, { borderColor: colorTokens.primary }]}
|
||||||
|
onPress={() => router.push('/training-plan' as any)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="查看训练计划"
|
||||||
|
>
|
||||||
|
<Text style={[styles.hintBtnText, { color: colorTokens.primary }]}>查看训练计划</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.planHintCard, { backgroundColor: colorTokens.card, borderColor: colorTokens.border }]}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.planHintTitle, { color: colorTokens.text }]}>你还没有训练计划</Text>
|
||||||
|
<Text style={[styles.planHintSub, { color: colorTokens.textMuted }]}>创建计划可明确每周节奏与目标(可跳过)</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.hintPrimaryBtn, { backgroundColor: colorTokens.primary }]}
|
||||||
|
onPress={() => router.push('/training-plan/create' as any)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="创建训练计划"
|
||||||
|
>
|
||||||
|
<Text style={[styles.hintPrimaryBtnText, { color: colorTokens.onPrimary }]}>创建训练计划</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.actionRow}>
|
<View style={styles.actionRow}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]}
|
style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]}
|
||||||
@@ -162,8 +219,7 @@ export default function CheckinHome() {
|
|||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
dispatch(removeExercise({ date: currentDate, key: exercise.key }));
|
dispatch(removeExercise({ date: currentDate, key: exercise.key }));
|
||||||
const nextItems: CheckinExercise[] = (record?.items || []).filter((it: CheckinExercise) => it.key !== exercise.key);
|
// 自动同步将由中间件处理
|
||||||
dispatch(syncCheckin({ date: currentDate, items: nextItems, note: record?.note }));
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -198,10 +254,7 @@ export default function CheckinHome() {
|
|||||||
style={styles.doneIconBtn}
|
style={styles.doneIconBtn}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
dispatch(toggleExerciseCompleted({ date: currentDate, key: exercise.key }));
|
dispatch(toggleExerciseCompleted({ date: currentDate, key: exercise.key }));
|
||||||
const nextItems: CheckinExercise[] = (record?.items || []).map((it: CheckinExercise) =>
|
// 自动同步将由中间件处理
|
||||||
it.key === exercise.key ? { ...it, completed: !it.completed } : it
|
|
||||||
);
|
|
||||||
dispatch(syncCheckin({ date: currentDate, items: nextItems, note: record?.note }));
|
|
||||||
}}
|
}}
|
||||||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
||||||
>
|
>
|
||||||
@@ -222,8 +275,7 @@ export default function CheckinHome() {
|
|||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
dispatch(removeExercise({ date: currentDate, key: exercise.key }));
|
dispatch(removeExercise({ date: currentDate, key: exercise.key }));
|
||||||
const nextItems: CheckinExercise[] = (record?.items || []).filter((it: CheckinExercise) => it.key !== exercise.key);
|
// 自动同步将由中间件处理
|
||||||
dispatch(syncCheckin({ date: currentDate, items: nextItems, note: record?.note }));
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -292,6 +344,14 @@ const styles = StyleSheet.create({
|
|||||||
primaryBtnText: { color: '#FFFFFF', fontWeight: '800' },
|
primaryBtnText: { color: '#FFFFFF', fontWeight: '800' },
|
||||||
secondaryBtn: { borderWidth: 2, paddingVertical: 10, borderRadius: 10, alignItems: 'center' },
|
secondaryBtn: { borderWidth: 2, paddingVertical: 10, borderRadius: 10, alignItems: 'center' },
|
||||||
secondaryBtnText: { fontWeight: '800' },
|
secondaryBtnText: { fontWeight: '800' },
|
||||||
|
// 训练计划提示卡片
|
||||||
|
planHintCard: { flexDirection: 'row', alignItems: 'center', gap: 10, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 12, borderWidth: 1 },
|
||||||
|
planHintTitle: { fontSize: 14, fontWeight: '800' },
|
||||||
|
planHintSub: { marginTop: 4, fontSize: 12 },
|
||||||
|
hintBtn: { paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, borderWidth: 1 },
|
||||||
|
hintBtnText: { fontWeight: '800' },
|
||||||
|
hintPrimaryBtn: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10 },
|
||||||
|
hintPrimaryBtnText: { fontWeight: '800' },
|
||||||
emptyBox: { marginTop: 16, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginHorizontal: 0 },
|
emptyBox: { marginTop: 16, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginHorizontal: 0 },
|
||||||
emptyText: { color: '#6B7280' },
|
emptyText: { color: '#6B7280' },
|
||||||
card: { marginTop: 12, marginHorizontal: 0, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, flexDirection: 'row', alignItems: 'center', gap: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 },
|
card: { marginTop: 12, marginHorizontal: 0, backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, flexDirection: 'row', alignItems: 'center', gap: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3 },
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch } from '@/hooks/redux';
|
import { useAppDispatch } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { addExercise, syncCheckin } from '@/store/checkinSlice';
|
import { fetchExerciseConfig, normalizeToLibraryItems } from '@/services/exercises';
|
||||||
import { EXERCISE_LIBRARY, getCategories, searchExercises } from '@/utils/exerciseLibrary';
|
import { addExercise } from '@/store/checkinSlice';
|
||||||
|
import { EXERCISE_LIBRARY, getCategories } from '@/utils/exerciseLibrary';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -34,6 +36,8 @@ export default function SelectExerciseScreen() {
|
|||||||
const [showCustomReps, setShowCustomReps] = useState(false);
|
const [showCustomReps, setShowCustomReps] = useState(false);
|
||||||
const [customRepsInput, setCustomRepsInput] = useState('');
|
const [customRepsInput, setCustomRepsInput] = useState('');
|
||||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||||
|
const [serverLibrary, setServerLibrary] = useState<{ key: string; name: string; description: string; category: string }[] | null>(null);
|
||||||
|
const [serverCategories, setServerCategories] = useState<string[] | null>(null);
|
||||||
|
|
||||||
const controlsOpacity = useRef(new Animated.Value(0)).current;
|
const controlsOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
@@ -42,8 +46,40 @@ export default function SelectExerciseScreen() {
|
|||||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
let aborted = false;
|
||||||
|
const CACHE_KEY = '@exercise_config_v1';
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const cached = await AsyncStorage.getItem(CACHE_KEY);
|
||||||
|
if (cached && !aborted) {
|
||||||
|
const parsed = JSON.parse(cached);
|
||||||
|
const items = normalizeToLibraryItems(parsed);
|
||||||
|
if (items.length) {
|
||||||
|
setServerLibrary(items);
|
||||||
|
const cats = Array.from(new Set(items.map((i) => i.category)));
|
||||||
|
setServerCategories(cats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const resp = await fetchExerciseConfig();
|
||||||
|
console.log('fetchExerciseConfig', resp);
|
||||||
|
if (aborted) return;
|
||||||
|
const items = normalizeToLibraryItems(resp);
|
||||||
|
setServerLibrary(items);
|
||||||
|
const cats = Array.from(new Set(items.map((i) => i.category)));
|
||||||
|
setServerCategories(cats);
|
||||||
|
try { await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(resp)); } catch {}
|
||||||
|
} catch (err) {}
|
||||||
|
})();
|
||||||
|
return () => { aborted = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const categories = useMemo(() => ['全部', ...getCategories()], []);
|
const categories = useMemo(() => {
|
||||||
|
const base = serverCategories ?? getCategories();
|
||||||
|
return ['全部', ...base];
|
||||||
|
}, [serverCategories]);
|
||||||
const mainCategories = useMemo(() => {
|
const mainCategories = useMemo(() => {
|
||||||
const preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑'];
|
const preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑'];
|
||||||
const exists = (name: string) => categories.includes(name);
|
const exists = (name: string) => categories.includes(name);
|
||||||
@@ -53,13 +89,17 @@ export default function SelectExerciseScreen() {
|
|||||||
while (picked.length < 5 && rest.length) picked.push(rest.shift() as string);
|
while (picked.length < 5 && rest.length) picked.push(rest.shift() as string);
|
||||||
return picked;
|
return picked;
|
||||||
}, [categories]);
|
}, [categories]);
|
||||||
|
const library = useMemo(() => serverLibrary ?? EXERCISE_LIBRARY, [serverLibrary]);
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const base = searchExercises(keyword);
|
const kw = keyword.trim().toLowerCase();
|
||||||
|
const base = kw
|
||||||
|
? library.filter((e) => e.name.toLowerCase().includes(kw) || (e.description || '').toLowerCase().includes(kw))
|
||||||
|
: library;
|
||||||
if (category === '全部') return base;
|
if (category === '全部') return base;
|
||||||
return base.filter((e) => e.category === category);
|
return base.filter((e) => e.category === category);
|
||||||
}, [keyword, category]);
|
}, [keyword, category, library]);
|
||||||
|
|
||||||
const selected = useMemo(() => EXERCISE_LIBRARY.find((e) => e.key === selectedKey) || null, [selectedKey]);
|
const selected = useMemo(() => library.find((e) => e.key === selectedKey) || null, [selectedKey, library]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Animated.timing(controlsOpacity, {
|
Animated.timing(controlsOpacity, {
|
||||||
@@ -82,20 +122,7 @@ export default function SelectExerciseScreen() {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
console.log('addExercise', currentDate, selected.key, sets, reps);
|
console.log('addExercise', currentDate, selected.key, sets, reps);
|
||||||
// 同步到后端(读取最新 store 需要在返回后由首页触发 load,或此处直接上报)
|
// 自动同步将由中间件处理,无需手动调用 syncCheckin
|
||||||
// 简单做法:直接上报新增项(其余项由后端合并/覆盖)
|
|
||||||
dispatch(syncCheckin({
|
|
||||||
date: currentDate,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: selected.key,
|
|
||||||
name: selected.name,
|
|
||||||
category: selected.category,
|
|
||||||
sets: Math.max(1, sets),
|
|
||||||
reps: reps && reps > 0 ? reps : undefined,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
router.back();
|
router.back();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function WelcomeScreen() {
|
|||||||
return (
|
return (
|
||||||
<ThemedView style={[styles.container, { backgroundColor }]}>
|
<ThemedView style={[styles.container, { backgroundColor }]}>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
|
barStyle={'dark-content'}
|
||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
@@ -216,7 +216,7 @@ export default function PersonalInfoScreen() {
|
|||||||
return (
|
return (
|
||||||
<ThemedView style={[styles.container, { backgroundColor }]}>
|
<ThemedView style={[styles.container, { backgroundColor }]}>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
|
barStyle={'dark-content'}
|
||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors, palette } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
@@ -7,6 +7,7 @@ import { updateUser as updateUserApi } from '@/services/users';
|
|||||||
import { fetchMyProfile } from '@/store/userSlice';
|
import { fetchMyProfile } from '@/store/userSlice';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
@@ -16,7 +17,9 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Image,
|
Image,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
Platform,
|
Platform,
|
||||||
|
Pressable,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
@@ -24,7 +27,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
@@ -34,7 +37,7 @@ type HeightUnit = 'cm' | 'ft';
|
|||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
name?: string;
|
name?: string;
|
||||||
gender?: 'male' | 'female' | '';
|
gender?: 'male' | 'female' | '';
|
||||||
age?: string; // 存储为字符串,方便非必填
|
birthDate?: string; // 出生日期
|
||||||
// 以公制为基准存储
|
// 以公制为基准存储
|
||||||
weight?: number; // kg
|
weight?: number; // kg
|
||||||
height?: number; // cm
|
height?: number; // cm
|
||||||
@@ -63,7 +66,7 @@ export default function EditProfileScreen() {
|
|||||||
const [profile, setProfile] = useState<UserProfile>({
|
const [profile, setProfile] = useState<UserProfile>({
|
||||||
name: '',
|
name: '',
|
||||||
gender: '',
|
gender: '',
|
||||||
age: '',
|
birthDate: '',
|
||||||
weight: undefined,
|
weight: undefined,
|
||||||
height: undefined,
|
height: undefined,
|
||||||
avatarUri: null,
|
avatarUri: null,
|
||||||
@@ -72,6 +75,10 @@ export default function EditProfileScreen() {
|
|||||||
const [weightInput, setWeightInput] = useState<string>('');
|
const [weightInput, setWeightInput] = useState<string>('');
|
||||||
const [heightInput, setHeightInput] = useState<string>('');
|
const [heightInput, setHeightInput] = useState<string>('');
|
||||||
|
|
||||||
|
// 出生日期选择器
|
||||||
|
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||||
|
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||||||
|
|
||||||
// 输入框字符串
|
// 输入框字符串
|
||||||
|
|
||||||
// 从本地存储加载(身高/体重等本地字段)
|
// 从本地存储加载(身高/体重等本地字段)
|
||||||
@@ -84,7 +91,7 @@ export default function EditProfileScreen() {
|
|||||||
let next: UserProfile = {
|
let next: UserProfile = {
|
||||||
name: '',
|
name: '',
|
||||||
gender: '',
|
gender: '',
|
||||||
age: '',
|
birthDate: '',
|
||||||
weight: undefined,
|
weight: undefined,
|
||||||
height: undefined,
|
height: undefined,
|
||||||
avatarUri: null,
|
avatarUri: null,
|
||||||
@@ -95,7 +102,7 @@ export default function EditProfileScreen() {
|
|||||||
|
|
||||||
if (o?.weight) next.weight = parseFloat(o.weight) || undefined;
|
if (o?.weight) next.weight = parseFloat(o.weight) || undefined;
|
||||||
if (o?.height) next.height = parseFloat(o.height) || undefined;
|
if (o?.height) next.height = parseFloat(o.height) || undefined;
|
||||||
if (o?.age) next.age = String(o.age);
|
if (o?.birthDate) next.birthDate = o.birthDate;
|
||||||
if (o?.gender) next.gender = o.gender;
|
if (o?.gender) next.gender = o.gender;
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
@@ -207,6 +214,24 @@ export default function EditProfileScreen() {
|
|||||||
|
|
||||||
const { upload, uploading } = useCosUpload();
|
const { upload, uploading } = useCosUpload();
|
||||||
|
|
||||||
|
// 出生日期选择器交互
|
||||||
|
const openDatePicker = () => {
|
||||||
|
const base = profile.birthDate ? new Date(profile.birthDate) : new Date();
|
||||||
|
base.setHours(0, 0, 0, 0);
|
||||||
|
setPickerDate(base);
|
||||||
|
setDatePickerVisible(true);
|
||||||
|
};
|
||||||
|
const closeDatePicker = () => setDatePickerVisible(false);
|
||||||
|
const onConfirmDate = (date: Date) => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const picked = new Date(date);
|
||||||
|
picked.setHours(0, 0, 0, 0);
|
||||||
|
const finalDate = picked > today ? today : picked;
|
||||||
|
setProfile((p) => ({ ...p, birthDate: finalDate.toISOString() }));
|
||||||
|
closeDatePicker();
|
||||||
|
};
|
||||||
|
|
||||||
const pickAvatarFromLibrary = async () => {
|
const pickAvatarFromLibrary = async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
@@ -245,7 +270,7 @@ export default function EditProfileScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
|
<SafeAreaView style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
|
||||||
<StatusBar barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'} />
|
<StatusBar barStyle={'dark-content'} />
|
||||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
|
||||||
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
|
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
|
||||||
<HeaderBar title="编辑资料" onBack={() => router.back()} withSafeTop={false} transparent />
|
<HeaderBar title="编辑资料" onBack={() => router.back()} withSafeTop={false} transparent />
|
||||||
@@ -332,18 +357,63 @@ export default function EditProfileScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 年龄 */}
|
{/* 出生日期 */}
|
||||||
<FieldLabel text="年龄" />
|
<FieldLabel text="出生日期" />
|
||||||
<View style={[styles.inputWrapper, { borderColor: '#E0E0E0' }]}>
|
<TouchableOpacity onPress={openDatePicker} activeOpacity={0.8} style={[styles.inputWrapper, { borderColor: '#E0E0E0' }]}>
|
||||||
<TextInput
|
<Text style={[styles.textInput, { color: profile.birthDate ? textColor : placeholderColor }]}>
|
||||||
style={[styles.textInput, { color: textColor }]}
|
{profile.birthDate
|
||||||
placeholder="填写年龄(可选)"
|
? (() => {
|
||||||
placeholderTextColor={placeholderColor}
|
try {
|
||||||
keyboardType="number-pad"
|
const d = new Date(profile.birthDate);
|
||||||
value={profile.age ?? ''}
|
return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }).format(d);
|
||||||
onChangeText={(t) => setProfile((p) => ({ ...p, age: t }))}
|
} catch {
|
||||||
/>
|
return profile.birthDate;
|
||||||
</View>
|
}
|
||||||
|
})()
|
||||||
|
: '选择出生日期(可选)'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 出生日期选择器弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={datePickerVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={closeDatePicker}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<DateTimePicker
|
||||||
|
value={pickerDate}
|
||||||
|
mode="date"
|
||||||
|
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||||
|
minimumDate={new Date(1900, 0, 1)}
|
||||||
|
maximumDate={new Date()}
|
||||||
|
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||||
|
onChange={(event, date) => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
if (date) setPickerDate(date);
|
||||||
|
} else {
|
||||||
|
if (event.type === 'set' && date) {
|
||||||
|
onConfirmDate(date);
|
||||||
|
} else {
|
||||||
|
closeDatePicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Platform.OS === 'ios' && (
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||||
|
<Text style={styles.modalBtnText}>取消</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||||
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* 保存按钮 */}
|
{/* 保存按钮 */}
|
||||||
<TouchableOpacity onPress={handleSave} activeOpacity={0.9} style={[styles.saveBtn, { backgroundColor: colors.primary }]}>
|
<TouchableOpacity onPress={handleSave} activeOpacity={0.9} style={[styles.saveBtn, { backgroundColor: colors.primary }]}>
|
||||||
@@ -465,6 +535,43 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
},
|
},
|
||||||
|
modalBackdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||||
|
},
|
||||||
|
modalSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginTop: 8,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modalBtn: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
},
|
||||||
|
modalBtnPrimary: {
|
||||||
|
backgroundColor: palette.primary,
|
||||||
|
},
|
||||||
|
modalBtnText: {
|
||||||
|
color: '#334155',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
modalBtnTextPrimary: {
|
||||||
|
color: '#0F172A',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { ThemedText } from '@/components/ThemedText';
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { palette } from '@/constants/Colors';
|
import { palette } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { clearError, deletePlan, loadPlans, setCurrentPlan, type TrainingPlan } from '@/store/trainingPlanSlice';
|
import { activatePlan, clearError, deletePlan, loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice';
|
||||||
|
|
||||||
|
|
||||||
const GOAL_TEXT: Record<string, { title: string; color: string; description: string }> = {
|
const GOAL_TEXT: Record<string, { title: string; color: string; description: string }> = {
|
||||||
@@ -184,6 +184,15 @@ export default function TrainingPlanListScreen() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [error, dispatch]);
|
}, [error, dispatch]);
|
||||||
|
|
||||||
|
|
||||||
|
const handleActivate = async (planId: string) => {
|
||||||
|
try {
|
||||||
|
await dispatch(activatePlan(planId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('激活训练计划失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.safeArea}>
|
<View style={styles.safeArea}>
|
||||||
@@ -243,7 +252,7 @@ export default function TrainingPlanListScreen() {
|
|||||||
index={index}
|
index={index}
|
||||||
isActive={p.id === currentId}
|
isActive={p.id === currentId}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
dispatch(setCurrentPlan(p.id));
|
router.push(`/training-plan/create?id=${p.id}` as any);
|
||||||
}}
|
}}
|
||||||
onDelete={() => dispatch(deletePlan(p.id))}
|
onDelete={() => dispatch(deletePlan(p.id))}
|
||||||
/>
|
/>
|
||||||
@@ -303,6 +312,7 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
color: '#192126',
|
color: '#192126',
|
||||||
|
lineHeight: 34,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
import { useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Modal, Platform, Pressable, SafeAreaView, ScrollView, StyleSheet, TextInput, View } from 'react-native';
|
import { Modal, Platform, Pressable, SafeAreaView, ScrollView, StyleSheet, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
@@ -38,7 +38,8 @@ const GOALS: { key: PlanGoal; title: string; desc: string }[] = [
|
|||||||
export default function TrainingPlanCreateScreen() {
|
export default function TrainingPlanCreateScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { draft, loading, error } = useAppSelector((s) => s.trainingPlan);
|
const { draft, loading, error, editingId } = useAppSelector((s) => s.trainingPlan);
|
||||||
|
const { id } = useLocalSearchParams<{ id?: string }>();
|
||||||
const [weightInput, setWeightInput] = useState<string>('');
|
const [weightInput, setWeightInput] = useState<string>('');
|
||||||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||||
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||||||
@@ -47,6 +48,17 @@ export default function TrainingPlanCreateScreen() {
|
|||||||
dispatch(loadPlans());
|
dispatch(loadPlans());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// 如果带有 id,加载详情并进入编辑模式
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
dispatch({ type: 'trainingPlan/clearError' } as any);
|
||||||
|
dispatch((require('@/store/trainingPlanSlice') as any).loadPlanForEdit(id as string));
|
||||||
|
} else {
|
||||||
|
// 离开编辑模式
|
||||||
|
dispatch((require('@/store/trainingPlanSlice') as any).setEditingId(null));
|
||||||
|
}
|
||||||
|
}, [id, dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draft.startWeightKg && !weightInput) setWeightInput(String(draft.startWeightKg));
|
if (draft.startWeightKg && !weightInput) setWeightInput(String(draft.startWeightKg));
|
||||||
}, [draft.startWeightKg]);
|
}, [draft.startWeightKg]);
|
||||||
@@ -76,7 +88,11 @@ export default function TrainingPlanCreateScreen() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
await dispatch(saveDraftAsPlan()).unwrap();
|
if (editingId) {
|
||||||
|
await dispatch((require('@/store/trainingPlanSlice') as any).updatePlanFromDraft()).unwrap();
|
||||||
|
} else {
|
||||||
|
await dispatch(saveDraftAsPlan()).unwrap();
|
||||||
|
}
|
||||||
router.back();
|
router.back();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 错误已经在Redux中处理,这里可以显示额外的用户反馈
|
// 错误已经在Redux中处理,这里可以显示额外的用户反馈
|
||||||
@@ -114,7 +130,7 @@ export default function TrainingPlanCreateScreen() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<HeaderBar title="新建训练计划" onBack={() => router.back()} withSafeTop={false} transparent />
|
<HeaderBar title={editingId ? '编辑训练计划' : '新建训练计划'} onBack={() => router.back()} withSafeTop={false} transparent />
|
||||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
|
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
|
||||||
<ThemedText style={styles.title}>制定你的训练计划</ThemedText>
|
<ThemedText style={styles.title}>制定你的训练计划</ThemedText>
|
||||||
<ThemedText style={styles.subtitle}>选择你的训练节奏与目标,我们将为你生成合适的普拉提安排。</ThemedText>
|
<ThemedText style={styles.subtitle}>选择你的训练节奏与目标,我们将为你生成合适的普拉提安排。</ThemedText>
|
||||||
@@ -242,7 +258,7 @@ export default function TrainingPlanCreateScreen() {
|
|||||||
|
|
||||||
<Pressable disabled={!canSave || loading} onPress={handleSave} style={[styles.primaryBtn, (!canSave || loading) && styles.primaryBtnDisabled]}>
|
<Pressable disabled={!canSave || loading} onPress={handleSave} style={[styles.primaryBtn, (!canSave || loading) && styles.primaryBtnDisabled]}>
|
||||||
<ThemedText style={styles.primaryBtnText}>
|
<ThemedText style={styles.primaryBtnText}>
|
||||||
{loading ? '创建中...' : canSave ? '生成计划' : '请先选择目标/频率'}
|
{loading ? (editingId ? '更新中...' : '创建中...') : canSave ? (editingId ? '更新计划' : '生成计划') : '请先选择目标/频率'}
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
export { useColorScheme } from 'react-native';
|
// 强制全局使用浅色主题:无论系统设置为何,统一返回 'light'
|
||||||
|
export function useColorScheme(): 'light' {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
// Web 端同样固定为浅色主题
|
||||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
export function useColorScheme(): 'light' {
|
||||||
|
|
||||||
/**
|
|
||||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
|
||||||
*/
|
|
||||||
export function useColorScheme() {
|
|
||||||
const [hasHydrated, setHasHydrated] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHasHydrated(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const colorScheme = useRNColorScheme();
|
|
||||||
|
|
||||||
if (hasHydrated) {
|
|
||||||
return colorScheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'light';
|
return 'light';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIUserInterfaceStyle</key>
|
<key>UIUserInterfaceStyle</key>
|
||||||
<string>Automatic</string>
|
<string>Light</string>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ export type RemoveCheckinDto = {
|
|||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SyncCheckinDto = {
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
items: any[]; // CheckinExercise[]
|
||||||
|
note?: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function createCheckin(dto: CreateCheckinDto): Promise<{ id: string } & Record<string, any>> {
|
export async function createCheckin(dto: CreateCheckinDto): Promise<{ id: string } & Record<string, any>> {
|
||||||
return await api.post('/api/checkins/create', dto);
|
return await api.post('/api/checkins/create', dto);
|
||||||
}
|
}
|
||||||
@@ -70,4 +77,11 @@ export async function fetchDailyStatusRange(startDate: string, endDate: string):
|
|||||||
return Array.isArray(data) ? data : [];
|
return Array.isArray(data) ? data : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步打卡数据到服务器
|
||||||
|
export async function syncCheckin(dto: SyncCheckinDto): Promise<{ id: string } & Record<string, any>> {
|
||||||
|
console.log('dto', dto);
|
||||||
|
|
||||||
|
return await api.post('/api/checkins/create', dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
44
services/exercises.ts
Normal file
44
services/exercises.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { api } from '@/services/api';
|
||||||
|
|
||||||
|
export type ExerciseCategoryDto = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExerciseDto = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
categoryKey: string;
|
||||||
|
categoryName: string;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExerciseConfigResponse = {
|
||||||
|
categories: ExerciseCategoryDto[];
|
||||||
|
exercises: ExerciseDto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExerciseLibraryItem = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string; // display name
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchExerciseConfig(): Promise<ExerciseConfigResponse> {
|
||||||
|
return await api.get<ExerciseConfigResponse>('/exercises/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeToLibraryItems(resp: ExerciseConfigResponse | null | undefined): ExerciseLibraryItem[] {
|
||||||
|
if (!resp || !Array.isArray(resp.exercises)) return [];
|
||||||
|
return resp.exercises.map((e) => ({
|
||||||
|
key: e.key,
|
||||||
|
name: e.name,
|
||||||
|
description: e.description,
|
||||||
|
category: e.categoryName || e.categoryKey,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +32,11 @@ export interface TrainingPlanSummary {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
goal: string;
|
goal: string;
|
||||||
|
mode: 'daysOfWeek' | 'sessionsPerWeek';
|
||||||
|
daysOfWeek: number[];
|
||||||
|
sessionsPerWeek: number;
|
||||||
|
preferredTimeOfDay: 'morning' | 'noon' | 'evening' | '';
|
||||||
|
startWeightKg: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrainingPlanListResponse {
|
export interface TrainingPlanListResponse {
|
||||||
@@ -54,9 +59,17 @@ class TrainingPlanApi {
|
|||||||
return api.get<TrainingPlanResponse>(`/training-plans/${id}`);
|
return api.get<TrainingPlanResponse>(`/training-plans/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: CreateTrainingPlanDto): Promise<TrainingPlanResponse> {
|
||||||
|
return api.post<TrainingPlanResponse>(`/training-plans/${id}/update`, dto);
|
||||||
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<{ success: boolean }> {
|
async delete(id: string): Promise<{ success: boolean }> {
|
||||||
return api.delete<{ success: boolean }>(`/training-plans/${id}`);
|
return api.delete<{ success: boolean }>(`/training-plans/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async activate(id: string): Promise<{ success: boolean }> {
|
||||||
|
return api.post<{ success: boolean }>(`/training-plans/${id}/activate`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const trainingPlanApi = new TrainingPlanApi();
|
export const trainingPlanApi = new TrainingPlanApi();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createCheckin, fetchCheckinsInRange, fetchDailyCheckins, updateCheckin } from '@/services/checkins';
|
import { createCheckin, fetchCheckinsInRange, fetchDailyCheckins, syncCheckin as syncCheckinApi, updateCheckin } from '@/services/checkins';
|
||||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createAction, createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
export type CheckinExercise = {
|
export type CheckinExercise = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -33,6 +33,7 @@ export type CheckinState = {
|
|||||||
byDate: Record<string, CheckinRecord>;
|
byDate: Record<string, CheckinRecord>;
|
||||||
currentDate: string | null;
|
currentDate: string | null;
|
||||||
monthLoaded: Record<string, boolean>; // key: YYYY-MM, 标记该月数据是否已加载
|
monthLoaded: Record<string, boolean>; // key: YYYY-MM, 标记该月数据是否已加载
|
||||||
|
pendingSyncDates?: string[]; // 待同步的日期列表
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: CheckinState = {
|
const initialState: CheckinState = {
|
||||||
@@ -41,6 +42,33 @@ const initialState: CheckinState = {
|
|||||||
monthLoaded: {},
|
monthLoaded: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function areItemsEqual(a: CheckinExercise[] | undefined, b: CheckinExercise[] | undefined): boolean {
|
||||||
|
if (!a && !b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
const ai = a[i];
|
||||||
|
const bi = b[i];
|
||||||
|
if (!ai || !bi) return false;
|
||||||
|
// 逐字段对比,避免 JSON 序列化引入顺序差异
|
||||||
|
if (
|
||||||
|
ai.key !== bi.key ||
|
||||||
|
ai.name !== bi.name ||
|
||||||
|
ai.category !== bi.category ||
|
||||||
|
(ai.itemType ?? 'exercise') !== (bi.itemType ?? 'exercise') ||
|
||||||
|
ai.sets !== bi.sets ||
|
||||||
|
ai.reps !== bi.reps ||
|
||||||
|
ai.durationSec !== bi.durationSec ||
|
||||||
|
ai.restSec !== bi.restSec ||
|
||||||
|
ai.note !== bi.note ||
|
||||||
|
(!!ai.completed) !== (!!bi.completed)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureRecord(state: CheckinState, date: string): CheckinRecord {
|
function ensureRecord(state: CheckinState, date: string): CheckinRecord {
|
||||||
if (!state.byDate[date]) {
|
if (!state.byDate[date]) {
|
||||||
state.byDate[date] = {
|
state.byDate[date] = {
|
||||||
@@ -52,6 +80,9 @@ function ensureRecord(state: CheckinState, date: string): CheckinRecord {
|
|||||||
return state.byDate[date];
|
return state.byDate[date];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 内部 action,用于标记需要同步的日期
|
||||||
|
export const triggerAutoSync = createAction<{ date: string }>('checkin/triggerAutoSync');
|
||||||
|
|
||||||
const checkinSlice = createSlice({
|
const checkinSlice = createSlice({
|
||||||
name: 'checkin',
|
name: 'checkin',
|
||||||
initialState,
|
initialState,
|
||||||
@@ -62,30 +93,55 @@ const checkinSlice = createSlice({
|
|||||||
},
|
},
|
||||||
addExercise(state, action: PayloadAction<{ date: string; item: CheckinExercise }>) {
|
addExercise(state, action: PayloadAction<{ date: string; item: CheckinExercise }>) {
|
||||||
const rec = ensureRecord(state, action.payload.date);
|
const rec = ensureRecord(state, action.payload.date);
|
||||||
// 若同 key 已存在则覆盖参数(更接近用户“重新选择/编辑”的心智)
|
// 若同 key 已存在则覆盖参数(更接近用户"重新选择/编辑"的心智)
|
||||||
const idx = rec.items.findIndex((it) => it.key === action.payload.item.key);
|
const idx = rec.items.findIndex((it) => it.key === action.payload.item.key);
|
||||||
const normalized: CheckinExercise = { ...action.payload.item, completed: false };
|
const normalized: CheckinExercise = { ...action.payload.item, completed: false };
|
||||||
if (idx >= 0) rec.items[idx] = normalized; else rec.items.push(normalized);
|
if (idx >= 0) rec.items[idx] = normalized; else rec.items.push(normalized);
|
||||||
|
// 标记需要同步
|
||||||
|
state.pendingSyncDates = state.pendingSyncDates || [];
|
||||||
|
if (!state.pendingSyncDates.includes(action.payload.date)) {
|
||||||
|
state.pendingSyncDates.push(action.payload.date);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
removeExercise(state, action: PayloadAction<{ date: string; key: string }>) {
|
removeExercise(state, action: PayloadAction<{ date: string; key: string }>) {
|
||||||
const rec = ensureRecord(state, action.payload.date);
|
const rec = ensureRecord(state, action.payload.date);
|
||||||
rec.items = rec.items.filter((it) => it.key !== action.payload.key);
|
rec.items = rec.items.filter((it) => it.key !== action.payload.key);
|
||||||
|
// 标记需要同步
|
||||||
|
state.pendingSyncDates = state.pendingSyncDates || [];
|
||||||
|
if (!state.pendingSyncDates.includes(action.payload.date)) {
|
||||||
|
state.pendingSyncDates.push(action.payload.date);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
replaceExercises(state, action: PayloadAction<{ date: string; items: CheckinExercise[]; note?: string }>) {
|
replaceExercises(state, action: PayloadAction<{ date: string; items: CheckinExercise[]; note?: string }>) {
|
||||||
const rec = ensureRecord(state, action.payload.date);
|
const rec = ensureRecord(state, action.payload.date);
|
||||||
rec.items = (action.payload.items || []).map((it) => ({ ...it, completed: false }));
|
rec.items = (action.payload.items || []).map((it) => ({ ...it, completed: false }));
|
||||||
if (typeof action.payload.note === 'string') rec.note = action.payload.note;
|
if (typeof action.payload.note === 'string') rec.note = action.payload.note;
|
||||||
|
// 标记需要同步
|
||||||
|
state.pendingSyncDates = state.pendingSyncDates || [];
|
||||||
|
if (!state.pendingSyncDates.includes(action.payload.date)) {
|
||||||
|
state.pendingSyncDates.push(action.payload.date);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
toggleExerciseCompleted(state, action: PayloadAction<{ date: string; key: string }>) {
|
toggleExerciseCompleted(state, action: PayloadAction<{ date: string; key: string }>) {
|
||||||
const rec = ensureRecord(state, action.payload.date);
|
const rec = ensureRecord(state, action.payload.date);
|
||||||
const idx = rec.items.findIndex((it) => it.key === action.payload.key);
|
const idx = rec.items.findIndex((it) => it.key === action.payload.key);
|
||||||
if (idx >= 0 && (rec.items[idx].itemType ?? 'exercise') === 'exercise') {
|
if (idx >= 0 && (rec.items[idx].itemType ?? 'exercise') === 'exercise') {
|
||||||
rec.items[idx].completed = !rec.items[idx].completed;
|
rec.items[idx].completed = !rec.items[idx].completed;
|
||||||
|
// 标记需要同步
|
||||||
|
state.pendingSyncDates = state.pendingSyncDates || [];
|
||||||
|
if (!state.pendingSyncDates.includes(action.payload.date)) {
|
||||||
|
state.pendingSyncDates.push(action.payload.date);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setNote(state, action: PayloadAction<{ date: string; note: string }>) {
|
setNote(state, action: PayloadAction<{ date: string; note: string }>) {
|
||||||
const rec = ensureRecord(state, action.payload.date);
|
const rec = ensureRecord(state, action.payload.date);
|
||||||
rec.note = action.payload.note;
|
rec.note = action.payload.note;
|
||||||
|
// 标记需要同步
|
||||||
|
state.pendingSyncDates = state.pendingSyncDates || [];
|
||||||
|
if (!state.pendingSyncDates.includes(action.payload.date)) {
|
||||||
|
state.pendingSyncDates.push(action.payload.date);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
resetDate(state, action: PayloadAction<string>) {
|
resetDate(state, action: PayloadAction<string>) {
|
||||||
delete state.byDate[action.payload];
|
delete state.byDate[action.payload];
|
||||||
@@ -96,12 +152,27 @@ const checkinSlice = createSlice({
|
|||||||
.addCase(syncCheckin.fulfilled, (state, action) => {
|
.addCase(syncCheckin.fulfilled, (state, action) => {
|
||||||
if (!action.payload) return;
|
if (!action.payload) return;
|
||||||
const { date, items, note, id } = action.payload;
|
const { date, items, note, id } = action.payload;
|
||||||
state.byDate[date] = {
|
const prev = state.byDate[date];
|
||||||
id: id || state.byDate[date]?.id || `rec_${date}`,
|
const nextId = id || prev?.id || `rec_${date}`;
|
||||||
date,
|
const nextItems = items || [];
|
||||||
items: items || [],
|
const isSame = prev && prev.id === nextId && prev.note === note && areItemsEqual(prev.items, nextItems);
|
||||||
note,
|
if (isSame) return;
|
||||||
};
|
state.byDate[date] = { id: nextId, date, items: nextItems, note };
|
||||||
|
})
|
||||||
|
.addCase(autoSyncCheckin.fulfilled, (state, action) => {
|
||||||
|
if (!action.payload) return;
|
||||||
|
const { date, items, note, id } = action.payload;
|
||||||
|
const prev = state.byDate[date];
|
||||||
|
if (prev) {
|
||||||
|
// 更新 ID(如果服务器返回了新的 ID)
|
||||||
|
if (id && id !== prev.id) {
|
||||||
|
state.byDate[date] = { ...prev, id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 清除同步标记
|
||||||
|
if (state.pendingSyncDates) {
|
||||||
|
state.pendingSyncDates = state.pendingSyncDates.filter(d => d !== date);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.addCase(getDailyCheckins.fulfilled, (state, action) => {
|
.addCase(getDailyCheckins.fulfilled, (state, action) => {
|
||||||
const date = action.payload.date as string | undefined;
|
const date = action.payload.date as string | undefined;
|
||||||
@@ -119,13 +190,18 @@ const checkinSlice = createSlice({
|
|||||||
}
|
}
|
||||||
if (typeof rec.notes === 'string') note = rec.notes as string;
|
if (typeof rec.notes === 'string') note = rec.notes as string;
|
||||||
}
|
}
|
||||||
state.byDate[date] = {
|
const prev = state.byDate[date];
|
||||||
id: id || state.byDate[date]?.id || `rec_${date}`,
|
const nextId = id || prev?.id || `rec_${date}`;
|
||||||
date,
|
const shouldUseRaw = (mergedItems?.length ?? 0) === 0;
|
||||||
items: mergedItems,
|
const isSameMain = prev && prev.id === nextId && prev.note === note && areItemsEqual(prev.items, mergedItems);
|
||||||
note,
|
if (isSameMain) {
|
||||||
raw: list,
|
// 若本地已有 items,则无需更新 raw 以免触发无效渲染
|
||||||
};
|
if (!shouldUseRaw) return;
|
||||||
|
// 若仅用于展示原始记录(本地 items 为空),允许更新 raw
|
||||||
|
state.byDate[date] = { ...prev, raw: list } as CheckinRecord;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.byDate[date] = { id: nextId, date, items: mergedItems, note, raw: shouldUseRaw ? list : prev?.raw };
|
||||||
})
|
})
|
||||||
.addCase(loadMonthCheckins.fulfilled, (state, action) => {
|
.addCase(loadMonthCheckins.fulfilled, (state, action) => {
|
||||||
const monthKey = action.payload.monthKey;
|
const monthKey = action.payload.monthKey;
|
||||||
@@ -168,6 +244,38 @@ export const syncCheckin = createAsyncThunk('checkin/sync', async (record: { dat
|
|||||||
return { id: newId, date: record.date, items: record.items, note: record.note };
|
return { id: newId, date: record.date, items: record.items, note: record.note };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 自动同步:当数据发生变动时自动调用
|
||||||
|
export const autoSyncCheckin = createAsyncThunk(
|
||||||
|
'checkin/autoSync',
|
||||||
|
async (payload: { date: string }, { getState, dispatch }) => {
|
||||||
|
const state = getState() as any;
|
||||||
|
const record = state?.checkin?.byDate?.[payload.date];
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
// 只有当有实际数据时才同步
|
||||||
|
if (!record.items || record.items.length === 0) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncCheckinApi({
|
||||||
|
date: payload.date,
|
||||||
|
items: record.items,
|
||||||
|
note: record.note,
|
||||||
|
id: record.id,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
date: payload.date,
|
||||||
|
items: record.items,
|
||||||
|
note: record.note,
|
||||||
|
id: result.id || record.id
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('自动同步失败:', error);
|
||||||
|
// 不抛出错误,避免影响用户操作
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// 获取当天打卡列表(用于进入页面时拉取最新云端数据)
|
// 获取当天打卡列表(用于进入页面时拉取最新云端数据)
|
||||||
export const getDailyCheckins = createAsyncThunk('checkin/getDaily', async (date?: string) => {
|
export const getDailyCheckins = createAsyncThunk('checkin/getDaily', async (date?: string) => {
|
||||||
const dateParam = date ?? new Date().toISOString().slice(0, 10);
|
const dateParam = date ?? new Date().toISOString().slice(0, 10);
|
||||||
|
|||||||
@@ -1,9 +1,37 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||||
import challengeReducer from './challengeSlice';
|
import challengeReducer from './challengeSlice';
|
||||||
import checkinReducer from './checkinSlice';
|
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
||||||
import trainingPlanReducer from './trainingPlanSlice';
|
import trainingPlanReducer from './trainingPlanSlice';
|
||||||
import userReducer from './userSlice';
|
import userReducer from './userSlice';
|
||||||
|
|
||||||
|
// 创建监听器中间件来处理自动同步
|
||||||
|
const listenerMiddleware = createListenerMiddleware();
|
||||||
|
|
||||||
|
// 监听所有数据变动的 actions,触发自动同步
|
||||||
|
const syncActions = [addExercise, removeExercise, replaceExercises, toggleExerciseCompleted, setNote];
|
||||||
|
syncActions.forEach(action => {
|
||||||
|
listenerMiddleware.startListening({
|
||||||
|
actionCreator: action,
|
||||||
|
effect: async (action, listenerApi) => {
|
||||||
|
const state = listenerApi.getState() as any;
|
||||||
|
const date = action.payload?.date;
|
||||||
|
|
||||||
|
if (!date) return;
|
||||||
|
|
||||||
|
// 延迟一下,避免在同一事件循环中重复触发
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// 检查是否还有待同步的日期
|
||||||
|
const currentState = listenerApi.getState() as any;
|
||||||
|
const pendingSyncDates = currentState?.checkin?.pendingSyncDates || [];
|
||||||
|
|
||||||
|
if (pendingSyncDates.includes(date)) {
|
||||||
|
listenerApi.dispatch(autoSyncCheckin({ date }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
user: userReducer,
|
user: userReducer,
|
||||||
@@ -11,7 +39,8 @@ export const store = configureStore({
|
|||||||
checkin: checkinReducer,
|
checkin: checkinReducer,
|
||||||
trainingPlan: trainingPlanReducer,
|
trainingPlan: trainingPlanReducer,
|
||||||
},
|
},
|
||||||
// React Native 环境默认即可
|
middleware: (getDefaultMiddleware) =>
|
||||||
|
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RootState = ReturnType<typeof store.getState>;
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CreateTrainingPlanDto, trainingPlanApi } from '@/services/trainingPlanApi';
|
import { CreateTrainingPlanDto, trainingPlanApi, TrainingPlanSummary } from '@/services/trainingPlanApi';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ export type TrainingPlan = {
|
|||||||
export type TrainingPlanState = {
|
export type TrainingPlanState = {
|
||||||
plans: TrainingPlan[];
|
plans: TrainingPlan[];
|
||||||
currentId?: string | null;
|
currentId?: string | null;
|
||||||
|
editingId?: string | null;
|
||||||
draft: Omit<TrainingPlan, 'id' | 'createdAt'>;
|
draft: Omit<TrainingPlan, 'id' | 'createdAt'>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -50,6 +51,7 @@ function nextMondayISO(): string {
|
|||||||
const initialState: TrainingPlanState = {
|
const initialState: TrainingPlanState = {
|
||||||
plans: [],
|
plans: [],
|
||||||
currentId: null,
|
currentId: null,
|
||||||
|
editingId: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
draft: {
|
draft: {
|
||||||
@@ -72,17 +74,7 @@ export const loadPlans = createAsyncThunk('trainingPlan/loadPlans', async (_, {
|
|||||||
// 尝试从服务器获取数据
|
// 尝试从服务器获取数据
|
||||||
const response = await trainingPlanApi.list(1, 100); // 获取所有计划
|
const response = await trainingPlanApi.list(1, 100); // 获取所有计划
|
||||||
console.log('response', response);
|
console.log('response', response);
|
||||||
const plans: TrainingPlan[] = response.list.map(summary => ({
|
const plans: TrainingPlanSummary[] = response.list;
|
||||||
id: summary.id,
|
|
||||||
createdAt: summary.createdAt,
|
|
||||||
startDate: summary.startDate,
|
|
||||||
goal: summary.goal as PlanGoal,
|
|
||||||
mode: 'daysOfWeek', // 默认值,需要从详情获取
|
|
||||||
daysOfWeek: [],
|
|
||||||
sessionsPerWeek: 3,
|
|
||||||
preferredTimeOfDay: '',
|
|
||||||
name: '',
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 读取最后一次使用的 currentId(从本地存储)
|
// 读取最后一次使用的 currentId(从本地存储)
|
||||||
const currentId = (await AsyncStorage.getItem(`${STORAGE_KEY_LIST}__currentId`)) || null;
|
const currentId = (await AsyncStorage.getItem(`${STORAGE_KEY_LIST}__currentId`)) || null;
|
||||||
@@ -170,6 +162,89 @@ export const saveDraftAsPlan = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载某个计划用于编辑:会写入 draft 与 editingId
|
||||||
|
*/
|
||||||
|
export const loadPlanForEdit = createAsyncThunk(
|
||||||
|
'trainingPlan/loadPlanForEdit',
|
||||||
|
async (id: string, { getState, rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const detail = await trainingPlanApi.detail(id);
|
||||||
|
const draft = {
|
||||||
|
startDate: detail.startDate,
|
||||||
|
name: detail.name,
|
||||||
|
mode: detail.mode,
|
||||||
|
daysOfWeek: detail.daysOfWeek,
|
||||||
|
sessionsPerWeek: detail.sessionsPerWeek,
|
||||||
|
goal: detail.goal as PlanGoal,
|
||||||
|
startWeightKg: detail.startWeightKg ?? undefined,
|
||||||
|
preferredTimeOfDay: detail.preferredTimeOfDay,
|
||||||
|
} as TrainingPlanState['draft'];
|
||||||
|
return { id, draft } as { id: string; draft: TrainingPlanState['draft'] };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '加载计划详情失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用当前 draft 更新 editingId 对应的计划
|
||||||
|
*/
|
||||||
|
export const updatePlanFromDraft = createAsyncThunk(
|
||||||
|
'trainingPlan/updatePlanFromDraft',
|
||||||
|
async (_: void, { getState, rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const s = (getState() as any).trainingPlan as TrainingPlanState;
|
||||||
|
if (!s.editingId) throw new Error('无有效编辑对象');
|
||||||
|
const draft = s.draft;
|
||||||
|
const dto: CreateTrainingPlanDto = {
|
||||||
|
startDate: draft.startDate,
|
||||||
|
name: draft.name,
|
||||||
|
mode: draft.mode,
|
||||||
|
daysOfWeek: draft.daysOfWeek,
|
||||||
|
sessionsPerWeek: draft.sessionsPerWeek,
|
||||||
|
goal: draft.goal,
|
||||||
|
startWeightKg: draft.startWeightKg,
|
||||||
|
preferredTimeOfDay: draft.preferredTimeOfDay,
|
||||||
|
};
|
||||||
|
const resp = await trainingPlanApi.update(s.editingId, dto);
|
||||||
|
const updated: TrainingPlan = {
|
||||||
|
id: resp.id,
|
||||||
|
createdAt: resp.createdAt,
|
||||||
|
startDate: resp.startDate,
|
||||||
|
mode: resp.mode,
|
||||||
|
daysOfWeek: resp.daysOfWeek,
|
||||||
|
sessionsPerWeek: resp.sessionsPerWeek,
|
||||||
|
goal: resp.goal as PlanGoal,
|
||||||
|
startWeightKg: resp.startWeightKg ?? undefined,
|
||||||
|
preferredTimeOfDay: resp.preferredTimeOfDay,
|
||||||
|
name: resp.name,
|
||||||
|
};
|
||||||
|
// 更新本地 plans
|
||||||
|
const idx = (s.plans || []).findIndex(p => p.id === updated.id);
|
||||||
|
const nextPlans = [...(s.plans || [])];
|
||||||
|
if (idx >= 0) nextPlans[idx] = updated; else nextPlans.push(updated);
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans));
|
||||||
|
return { plans: nextPlans } as { plans: TrainingPlan[] };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '更新训练计划失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 激活计划
|
||||||
|
export const activatePlan = createAsyncThunk(
|
||||||
|
'trainingPlan/activatePlan',
|
||||||
|
async (planId: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await trainingPlanApi.activate(planId);
|
||||||
|
return { id: planId } as { id: string };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '激活训练计划失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/** 删除计划 */
|
/** 删除计划 */
|
||||||
export const deletePlan = createAsyncThunk(
|
export const deletePlan = createAsyncThunk(
|
||||||
'trainingPlan/deletePlan',
|
'trainingPlan/deletePlan',
|
||||||
@@ -244,6 +319,9 @@ const trainingPlanSlice = createSlice({
|
|||||||
clearError(state) {
|
clearError(state) {
|
||||||
state.error = null;
|
state.error = null;
|
||||||
},
|
},
|
||||||
|
setEditingId(state, action: PayloadAction<string | null>) {
|
||||||
|
state.editingId = action.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder
|
builder
|
||||||
@@ -281,6 +359,48 @@ const trainingPlanSlice = createSlice({
|
|||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.error = action.payload as string || '创建训练计划失败';
|
state.error = action.payload as string || '创建训练计划失败';
|
||||||
})
|
})
|
||||||
|
// loadPlanForEdit
|
||||||
|
.addCase(loadPlanForEdit.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(loadPlanForEdit.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.editingId = action.payload.id;
|
||||||
|
state.draft = action.payload.draft;
|
||||||
|
})
|
||||||
|
.addCase(loadPlanForEdit.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string || '加载计划详情失败';
|
||||||
|
})
|
||||||
|
// updatePlanFromDraft
|
||||||
|
.addCase(updatePlanFromDraft.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(updatePlanFromDraft.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.plans = action.payload.plans;
|
||||||
|
})
|
||||||
|
.addCase(updatePlanFromDraft.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string || '更新训练计划失败';
|
||||||
|
})
|
||||||
|
// activatePlan
|
||||||
|
.addCase(activatePlan.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(activatePlan.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.currentId = action.payload.id;
|
||||||
|
// 保存到本地存储
|
||||||
|
AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, action.payload.id);
|
||||||
|
})
|
||||||
|
.addCase(activatePlan.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string || '激活训练计划失败';
|
||||||
|
})
|
||||||
// deletePlan
|
// deletePlan
|
||||||
.addCase(deletePlan.pending, (state) => {
|
.addCase(deletePlan.pending, (state) => {
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
@@ -311,6 +431,7 @@ export const {
|
|||||||
setStartDateNextMonday,
|
setStartDateNextMonday,
|
||||||
resetDraft,
|
resetDraft,
|
||||||
clearError,
|
clearError,
|
||||||
|
setEditingId,
|
||||||
} = trainingPlanSlice.actions;
|
} = trainingPlanSlice.actions;
|
||||||
|
|
||||||
export default trainingPlanSlice.reducer;
|
export default trainingPlanSlice.reducer;
|
||||||
|
|||||||
Reference in New Issue
Block a user