feat: 更新应用版本和主题设置
- 将应用版本更新至 1.0.3,修改相关配置文件 - 强制全局使用浅色主题,确保一致的用户体验 - 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划 - 优化打卡功能,支持自动同步打卡记录至服务器 - 更新样式以适应新功能的展示和交互
This commit is contained in:
@@ -3,10 +3,10 @@ import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
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 { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
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 today = useMemo(() => formatDate(new Date()), []);
|
||||
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 currentDate: string = routeDateParam || (checkin?.currentDate as string) || today;
|
||||
const record = checkin?.byDate?.[currentDate] as (undefined | { items?: CheckinExercise[]; note?: string; raw?: any[] });
|
||||
@@ -52,18 +53,15 @@ export default function CheckinHome() {
|
||||
}
|
||||
}, [dispatch, currentDate]);
|
||||
|
||||
const lastSyncSigRef = useRef<string>('');
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
// 仅当本地条目发生变更时才上报,避免反复刷写
|
||||
const sig = JSON.stringify(record?.items || []);
|
||||
if (record?.items && Array.isArray(record.items) && sig !== lastSyncSigRef.current) {
|
||||
lastSyncSigRef.current = sig;
|
||||
dispatch(syncCheckin({ date: currentDate, items: record.items as CheckinExercise[], note: record?.note }));
|
||||
}
|
||||
return () => { };
|
||||
}, [dispatch, currentDate, record?.items, record?.note])
|
||||
);
|
||||
// 加载训练计划列表:仅在页面挂载时尝试一次,避免因失败导致的重复请求
|
||||
const hasLoadedPlansRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasLoadedPlansRef.current) return;
|
||||
hasLoadedPlansRef.current = true;
|
||||
dispatch(loadPlans());
|
||||
}, [dispatch]);
|
||||
|
||||
// 同步触发逻辑改为显式操作处调用,避免页面渲染期间的副作用
|
||||
|
||||
const [genVisible, setGenVisible] = useState(false);
|
||||
const [genLevel, setGenLevel] = useState<'beginner' | 'intermediate' | 'advanced'>('beginner');
|
||||
@@ -71,11 +69,33 @@ export default function CheckinHome() {
|
||||
const [genWithNotes, setGenWithNotes] = useState(true);
|
||||
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 restSec = Math.max(10, Math.min(120, parseInt(genRest || '30', 10)));
|
||||
const { items, note } = buildClassicalSession({ withSectionRests: genWithRests, restSeconds: restSec, withNotes: genWithNotes, level: genLevel });
|
||||
dispatch(replaceExercises({ date: currentDate, items, note }));
|
||||
dispatch(syncCheckin({ date: currentDate, items, note }));
|
||||
// 自动同步将由中间件处理
|
||||
setGenVisible(false);
|
||||
Alert.alert('排课已生成', '已为你生成经典普拉提序列,可继续调整。');
|
||||
};
|
||||
@@ -94,6 +114,43 @@ export default function CheckinHome() {
|
||||
<Text style={[styles.subtitle, { color: colorTokens.textMuted }]}>请选择动作并记录完成情况</Text>
|
||||
</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}>
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, { backgroundColor: colorTokens.primary }]}
|
||||
@@ -162,8 +219,7 @@ export default function CheckinHome() {
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
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}
|
||||
onPress={() => {
|
||||
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 }}
|
||||
>
|
||||
@@ -222,8 +275,7 @@ export default function CheckinHome() {
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
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' },
|
||||
secondaryBtn: { borderWidth: 2, paddingVertical: 10, borderRadius: 10, alignItems: 'center' },
|
||||
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 },
|
||||
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 },
|
||||
|
||||
@@ -2,9 +2,11 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { addExercise, syncCheckin } from '@/store/checkinSlice';
|
||||
import { EXERCISE_LIBRARY, getCategories, searchExercises } from '@/utils/exerciseLibrary';
|
||||
import { fetchExerciseConfig, normalizeToLibraryItems } from '@/services/exercises';
|
||||
import { addExercise } from '@/store/checkinSlice';
|
||||
import { EXERCISE_LIBRARY, getCategories } from '@/utils/exerciseLibrary';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -34,6 +36,8 @@ export default function SelectExerciseScreen() {
|
||||
const [showCustomReps, setShowCustomReps] = useState(false);
|
||||
const [customRepsInput, setCustomRepsInput] = useState('');
|
||||
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;
|
||||
|
||||
@@ -42,8 +46,40 @@ export default function SelectExerciseScreen() {
|
||||
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 preferred = ['全部', '核心与腹部', '脊柱与后链', '侧链与髋', '平衡与支撑'];
|
||||
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);
|
||||
return picked;
|
||||
}, [categories]);
|
||||
const library = useMemo(() => serverLibrary ?? EXERCISE_LIBRARY, [serverLibrary]);
|
||||
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;
|
||||
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(() => {
|
||||
Animated.timing(controlsOpacity, {
|
||||
@@ -82,20 +122,7 @@ export default function SelectExerciseScreen() {
|
||||
},
|
||||
}));
|
||||
console.log('addExercise', currentDate, selected.key, sets, reps);
|
||||
// 同步到后端(读取最新 store 需要在返回后由首页触发 load,或此处直接上报)
|
||||
// 简单做法:直接上报新增项(其余项由后端合并/覆盖)
|
||||
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,
|
||||
},
|
||||
],
|
||||
}));
|
||||
// 自动同步将由中间件处理,无需手动调用 syncCheckin
|
||||
router.back();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user