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

@@ -10,6 +10,7 @@ import { fetchRecommendations, RecommendationType } from '@/services/recommendat
// Removed WorkoutCard import since we no longer use the horizontal carousel
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { getChineseGreeting } from '@/utils/date';
import dayjs from 'dayjs';
import { useRouter } from 'expo-router';
import React from 'react';
import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
@@ -137,7 +138,6 @@ export default function HomeScreen() {
try {
const cards = await fetchRecommendations();
console.log('fetchRecommendations', cards);
if (canceled) return;
const mapped: RecommendItem[] = [];
for (const c of cards || []) {
@@ -161,7 +161,7 @@ export default function HomeScreen() {
title: c.title || '今日打卡',
subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持',
progress: 0,
onPress: () => pushIfAuthedElseLogin('/checkin'),
onPress: () => pushIfAuthedElseLogin('/checkin?date=' + dayjs().format('YYYY-MM-DD')),
});
}
}
@@ -258,7 +258,7 @@ export default function HomeScreen() {
<Pressable
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.featureSubtitle}> · </ThemedText>

View File

@@ -343,7 +343,7 @@ export default function PersonalScreen() {
return (
<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 }]}>
<ScrollView
style={[styles.scrollView, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}

View File

@@ -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 { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
@@ -36,7 +36,7 @@ export default function RootLayout() {
return (
<Provider store={store}>
<Bootstrapper>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<ThemeProvider value={DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
@@ -52,7 +52,7 @@ export default function RootLayout() {
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
<StatusBar style="dark" />
</ThemeProvider>
</Bootstrapper>
</Provider>

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

View File

@@ -39,7 +39,7 @@ export default function WelcomeScreen() {
return (
<ThemedView style={[styles.container, { backgroundColor }]}>
<StatusBar
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
barStyle={'dark-content'}
backgroundColor={backgroundColor}
/>

View File

@@ -6,15 +6,15 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { router } from 'expo-router';
import React, { useState } from 'react';
import {
Alert,
Dimensions,
ScrollView,
StatusBar,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
Alert,
Dimensions,
ScrollView,
StatusBar,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
const { width } = Dimensions.get('window');
@@ -216,7 +216,7 @@ export default function PersonalInfoScreen() {
return (
<ThemedView style={[styles.container, { backgroundColor }]}>
<StatusBar
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
barStyle={'dark-content'}
backgroundColor={backgroundColor}
/>

View File

@@ -1,5 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { Colors, palette } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
@@ -7,6 +7,7 @@ import { updateUser as updateUserApi } from '@/services/users';
import { fetchMyProfile } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import DateTimePicker from '@react-native-community/datetimepicker';
import { useFocusEffect } from '@react-navigation/native';
import * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router';
@@ -16,7 +17,9 @@ import {
Alert,
Image,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
SafeAreaView,
ScrollView,
StatusBar,
@@ -24,7 +27,7 @@ import {
Text,
TextInput,
TouchableOpacity,
View
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -34,7 +37,7 @@ type HeightUnit = 'cm' | 'ft';
interface UserProfile {
name?: string;
gender?: 'male' | 'female' | '';
age?: string; // 存储为字符串,方便非必填
birthDate?: string; // 出生日期
// 以公制为基准存储
weight?: number; // kg
height?: number; // cm
@@ -63,7 +66,7 @@ export default function EditProfileScreen() {
const [profile, setProfile] = useState<UserProfile>({
name: '',
gender: '',
age: '',
birthDate: '',
weight: undefined,
height: undefined,
avatarUri: null,
@@ -72,6 +75,10 @@ export default function EditProfileScreen() {
const [weightInput, setWeightInput] = 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 = {
name: '',
gender: '',
age: '',
birthDate: '',
weight: undefined,
height: undefined,
avatarUri: null,
@@ -95,7 +102,7 @@ export default function EditProfileScreen() {
if (o?.weight) next.weight = parseFloat(o.weight) || 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;
} catch { }
}
@@ -207,6 +214,24 @@ export default function EditProfileScreen() {
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 () => {
try {
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
@@ -245,7 +270,7 @@ export default function EditProfileScreen() {
return (
<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 }}>
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
<HeaderBar title="编辑资料" onBack={() => router.back()} withSafeTop={false} transparent />
@@ -332,18 +357,63 @@ export default function EditProfileScreen() {
</TouchableOpacity>
</View>
{/* 年龄 */}
<FieldLabel text="年龄" />
<View style={[styles.inputWrapper, { borderColor: '#E0E0E0' }]}>
<TextInput
style={[styles.textInput, { color: textColor }]}
placeholder="填写年龄(可选)"
placeholderTextColor={placeholderColor}
keyboardType="number-pad"
value={profile.age ?? ''}
onChangeText={(t) => setProfile((p) => ({ ...p, age: t }))}
/>
</View>
{/* 出生日期 */}
<FieldLabel text="出生日期" />
<TouchableOpacity onPress={openDatePicker} activeOpacity={0.8} style={[styles.inputWrapper, { borderColor: '#E0E0E0' }]}>
<Text style={[styles.textInput, { color: profile.birthDate ? textColor : placeholderColor }]}>
{profile.birthDate
? (() => {
try {
const d = new Date(profile.birthDate);
return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }).format(d);
} catch {
return profile.birthDate;
}
})()
: '选择出生日期(可选)'}
</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 }]}>
@@ -465,6 +535,43 @@ const styles = StyleSheet.create({
shadowRadius: 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: {
flexDirection: 'row',
alignItems: 'center',

View File

@@ -18,7 +18,7 @@ import { ThemedText } from '@/components/ThemedText';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { palette } from '@/constants/Colors';
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 }> = {
@@ -184,6 +184,15 @@ export default function TrainingPlanListScreen() {
return () => clearTimeout(timer);
}
}, [error, dispatch]);
const handleActivate = async (planId: string) => {
try {
await dispatch(activatePlan(planId));
} catch (error) {
console.error('激活训练计划失败:', error);
}
}
return (
<View style={styles.safeArea}>
@@ -243,7 +252,7 @@ export default function TrainingPlanListScreen() {
index={index}
isActive={p.id === currentId}
onPress={() => {
dispatch(setCurrentPlan(p.id));
router.push(`/training-plan/create?id=${p.id}` as any);
}}
onDelete={() => dispatch(deletePlan(p.id))}
/>
@@ -303,6 +312,7 @@ const styles = StyleSheet.create({
fontSize: 28,
fontWeight: '800',
color: '#192126',
lineHeight: 34,
marginBottom: 4,
},
subtitle: {

View File

@@ -1,5 +1,5 @@
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 { 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() {
const router = useRouter();
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 [datePickerVisible, setDatePickerVisible] = useState(false);
const [pickerDate, setPickerDate] = useState<Date>(new Date());
@@ -47,6 +48,17 @@ export default function TrainingPlanCreateScreen() {
dispatch(loadPlans());
}, [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(() => {
if (draft.startWeightKg && !weightInput) setWeightInput(String(draft.startWeightKg));
}, [draft.startWeightKg]);
@@ -76,7 +88,11 @@ export default function TrainingPlanCreateScreen() {
const handleSave = async () => {
try {
await dispatch(saveDraftAsPlan()).unwrap();
if (editingId) {
await dispatch((require('@/store/trainingPlanSlice') as any).updatePlanFromDraft()).unwrap();
} else {
await dispatch(saveDraftAsPlan()).unwrap();
}
router.back();
} catch (error) {
// 错误已经在Redux中处理这里可以显示额外的用户反馈
@@ -114,7 +130,7 @@ export default function TrainingPlanCreateScreen() {
return (
<SafeAreaView style={styles.safeArea}>
<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}>
<ThemedText style={styles.title}></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]}>
<ThemedText style={styles.primaryBtnText}>
{loading ? '创建中...' : canSave ? '生成计划' : '请先选择目标/频率'}
{loading ? (editingId ? '更新中...' : '创建中...') : canSave ? (editingId ? '更新计划' : '生成计划') : '请先选择目标/频率'}
</ThemedText>
</Pressable>