feat: 更新应用版本和主题设置

- 将应用版本更新至 1.0.3,修改相关配置文件
- 强制全局使用浅色主题,确保一致的用户体验
- 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划
- 优化打卡功能,支持自动同步打卡记录至服务器
- 更新样式以适应新功能的展示和交互
This commit is contained in:
2025-08-14 22:23:45 +08:00
parent 56d4c7fd7f
commit 807e185761
21 changed files with 677 additions and 141 deletions

View File

@@ -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 },

View File

@@ -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();
};