feat: 更新应用图标和启动画面
- 将应用图标更改为 logo.jpeg,更新相关配置文件 - 删除旧的图标文件,确保资源整洁 - 更新启动画面使用新的 logo 图片,提升视觉一致性 - 在训练计划相关功能中集成新的 API 接口,支持训练计划的创建和管理 - 优化 Redux 状态管理,支持训练计划的加载和删除功能 - 更新样式以适应新图标和功能的展示
8
app.json
@@ -4,7 +4,7 @@
|
|||||||
"slug": "digital-pilates",
|
"slug": "digital-pilates",
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/logo.jpeg",
|
||||||
"scheme": "digitalpilates",
|
"scheme": "digitalpilates",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
"foregroundImage": "./assets/images/logo.jpeg",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
@@ -30,14 +30,14 @@
|
|||||||
"web": {
|
"web": {
|
||||||
"bundler": "metro",
|
"bundler": "metro",
|
||||||
"output": "static",
|
"output": "static",
|
||||||
"favicon": "./assets/images/favicon.png"
|
"favicon": "./assets/images/logo.jpeg"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/images/logo.jpeg",
|
||||||
"imageWidth": 200,
|
"imageWidth": 200,
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ export default function AICoachChatScreen() {
|
|||||||
setKeyboardOffset(height);
|
setKeyboardOffset(height);
|
||||||
} catch { setKeyboardOffset(0); }
|
} catch { setKeyboardOffset(0); }
|
||||||
});
|
});
|
||||||
|
hideSub = Keyboard.addListener('keyboardWillHide', () => setKeyboardOffset(0));
|
||||||
} else {
|
} else {
|
||||||
showSub = Keyboard.addListener('keyboardDidShow', (e: any) => {
|
showSub = Keyboard.addListener('keyboardDidShow', (e: any) => {
|
||||||
try { setKeyboardOffset(Math.max(0, e.endCoordinates?.height ?? 0)); } catch { setKeyboardOffset(0); }
|
try { setKeyboardOffset(Math.max(0, e.endCoordinates?.height ?? 0)); } catch { setKeyboardOffset(0); }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as ImagePicker from 'expo-image-picker';
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
Image,
|
Image,
|
||||||
Linking,
|
Linking,
|
||||||
@@ -12,13 +13,14 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import ImageViewing from 'react-native-image-viewing';
|
import ImageViewing from 'react-native-image-viewing';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
|
|
||||||
type PoseView = 'front' | 'side' | 'back';
|
type PoseView = 'front' | 'side' | 'back';
|
||||||
|
|
||||||
@@ -59,6 +61,9 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
[uploadState]
|
[uploadState]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { upload, uploading } = useCosUpload();
|
||||||
|
const [uploadingKey, setUploadingKey] = useState<PoseView | null>(null);
|
||||||
|
|
||||||
const [cameraPerm, setCameraPerm] = useState<ImagePicker.PermissionStatus | null>(null);
|
const [cameraPerm, setCameraPerm] = useState<ImagePicker.PermissionStatus | null>(null);
|
||||||
const [libraryPerm, setLibraryPerm] = useState<ImagePicker.PermissionStatus | null>(null);
|
const [libraryPerm, setLibraryPerm] = useState<ImagePicker.PermissionStatus | null>(null);
|
||||||
const [libraryAccess, setLibraryAccess] = useState<'all' | 'limited' | 'none' | null>(null);
|
const [libraryAccess, setLibraryAccess] = useState<'all' | 'limited' | 'none' | null>(null);
|
||||||
@@ -129,7 +134,25 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
aspect: [3, 4],
|
aspect: [3, 4],
|
||||||
});
|
});
|
||||||
if (!result.canceled) {
|
if (!result.canceled) {
|
||||||
setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? null }));
|
// 设置正在上传状态
|
||||||
|
setUploadingKey(key);
|
||||||
|
try {
|
||||||
|
// 上传到 COS
|
||||||
|
const { url } = await upload(
|
||||||
|
{ uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' },
|
||||||
|
{ prefix: 'posture-assessment/' }
|
||||||
|
);
|
||||||
|
// 上传成功,更新状态
|
||||||
|
setUploadState((s) => ({ ...s, [key]: url }));
|
||||||
|
} catch (uploadError) {
|
||||||
|
console.warn('上传图片失败', uploadError);
|
||||||
|
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||||
|
// 上传失败,清除状态
|
||||||
|
setUploadState((s) => ({ ...s, [key]: null }));
|
||||||
|
} finally {
|
||||||
|
// 清除上传状态
|
||||||
|
setUploadingKey(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
@@ -158,7 +181,25 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
aspect: [3, 4],
|
aspect: [3, 4],
|
||||||
});
|
});
|
||||||
if (!result.canceled) {
|
if (!result.canceled) {
|
||||||
setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? null }));
|
// 设置正在上传状态
|
||||||
|
setUploadingKey(key);
|
||||||
|
try {
|
||||||
|
// 上传到 COS
|
||||||
|
const { url } = await upload(
|
||||||
|
{ uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' },
|
||||||
|
{ prefix: 'posture-assessment/' }
|
||||||
|
);
|
||||||
|
// 上传成功,更新状态
|
||||||
|
setUploadState((s) => ({ ...s, [key]: url }));
|
||||||
|
} catch (uploadError) {
|
||||||
|
console.warn('上传图片失败', uploadError);
|
||||||
|
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||||
|
// 上传失败,清除状态
|
||||||
|
setUploadState((s) => ({ ...s, [key]: null }));
|
||||||
|
} finally {
|
||||||
|
// 清除上传状态
|
||||||
|
setUploadingKey(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -219,6 +260,7 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
onPickCamera={() => requestPermissionAndPick('camera', 'front')}
|
onPickCamera={() => requestPermissionAndPick('camera', 'front')}
|
||||||
onPickLibrary={() => requestPermissionAndPick('library', 'front')}
|
onPickLibrary={() => requestPermissionAndPick('library', 'front')}
|
||||||
samples={SAMPLES.front}
|
samples={SAMPLES.front}
|
||||||
|
uploading={uploading && uploadingKey === 'front'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadTile
|
<UploadTile
|
||||||
@@ -227,6 +269,7 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
onPickCamera={() => requestPermissionAndPick('camera', 'side')}
|
onPickCamera={() => requestPermissionAndPick('camera', 'side')}
|
||||||
onPickLibrary={() => requestPermissionAndPick('library', 'side')}
|
onPickLibrary={() => requestPermissionAndPick('library', 'side')}
|
||||||
samples={SAMPLES.side}
|
samples={SAMPLES.side}
|
||||||
|
uploading={uploading && uploadingKey === 'side'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadTile
|
<UploadTile
|
||||||
@@ -235,6 +278,7 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
onPickCamera={() => requestPermissionAndPick('camera', 'back')}
|
onPickCamera={() => requestPermissionAndPick('camera', 'back')}
|
||||||
onPickLibrary={() => requestPermissionAndPick('library', 'back')}
|
onPickLibrary={() => requestPermissionAndPick('library', 'back')}
|
||||||
samples={SAMPLES.back}
|
samples={SAMPLES.back}
|
||||||
|
uploading={uploading && uploadingKey === 'back'}
|
||||||
/>
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
@@ -264,12 +308,14 @@ function UploadTile({
|
|||||||
onPickCamera,
|
onPickCamera,
|
||||||
onPickLibrary,
|
onPickLibrary,
|
||||||
samples,
|
samples,
|
||||||
|
uploading,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
onPickCamera: () => void;
|
onPickCamera: () => void;
|
||||||
onPickLibrary: () => void;
|
onPickLibrary: () => void;
|
||||||
samples: Sample[];
|
samples: Sample[];
|
||||||
|
uploading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [viewerVisible, setViewerVisible] = React.useState(false);
|
const [viewerVisible, setViewerVisible] = React.useState(false);
|
||||||
const [viewerIndex, setViewerIndex] = React.useState(0);
|
const [viewerIndex, setViewerIndex] = React.useState(0);
|
||||||
@@ -291,8 +337,14 @@ function UploadTile({
|
|||||||
onLongPress={onPickLibrary}
|
onLongPress={onPickLibrary}
|
||||||
onPress={onPickCamera}
|
onPress={onPickCamera}
|
||||||
style={styles.uploader}
|
style={styles.uploader}
|
||||||
|
disabled={uploading}
|
||||||
>
|
>
|
||||||
{value ? (
|
{uploading ? (
|
||||||
|
<View style={[styles.placeholder, { backgroundColor: '#f5f5f5' }]}>
|
||||||
|
<ActivityIndicator size="large" color="#BBF246" />
|
||||||
|
<Text style={styles.placeholderTitle}>上传中...</Text>
|
||||||
|
</View>
|
||||||
|
) : value ? (
|
||||||
<Image source={{ uri: value }} style={styles.preview} />
|
<Image source={{ uri: value }} style={styles.preview} />
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.placeholder}>
|
<View style={styles.placeholder}>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import * as ImagePicker from 'expo-image-picker';
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
Image,
|
Image,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -23,7 +24,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';
|
||||||
|
|
||||||
@@ -230,6 +231,7 @@ export default function EditProfileScreen() {
|
|||||||
{ uri: asset.uri, name: asset.fileName || 'avatar.jpg', type: asset.mimeType || 'image/jpeg' },
|
{ uri: asset.uri, name: asset.fileName || 'avatar.jpg', type: asset.mimeType || 'image/jpeg' },
|
||||||
{ prefix: 'avatars/', userId }
|
{ prefix: 'avatars/', userId }
|
||||||
);
|
);
|
||||||
|
|
||||||
setProfile((p) => ({ ...p, avatarUri: url, avatarBase64: null }));
|
setProfile((p) => ({ ...p, avatarUri: url, avatarBase64: null }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('上传头像失败', e);
|
console.warn('上传头像失败', e);
|
||||||
@@ -250,12 +252,17 @@ export default function EditProfileScreen() {
|
|||||||
|
|
||||||
{/* 头像(带相机蒙层,点击从相册选择) */}
|
{/* 头像(带相机蒙层,点击从相册选择) */}
|
||||||
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 16 }}>
|
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 16 }}>
|
||||||
<TouchableOpacity activeOpacity={0.85} onPress={pickAvatarFromLibrary}>
|
<TouchableOpacity activeOpacity={0.85} onPress={pickAvatarFromLibrary} disabled={uploading}>
|
||||||
<View style={styles.avatarCircle}>
|
<View style={styles.avatarCircle}>
|
||||||
<Image source={{ uri: profile.avatarUri || 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg' }} style={styles.avatarImage} />
|
<Image source={{ uri: profile.avatarUri || 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg' }} style={styles.avatarImage} />
|
||||||
<View style={styles.avatarOverlay}>
|
<View style={styles.avatarOverlay}>
|
||||||
<Ionicons name="camera" size={22} color="#192126" />
|
<Ionicons name="camera" size={22} color="#192126" />
|
||||||
</View>
|
</View>
|
||||||
|
{uploading && (
|
||||||
|
<View style={styles.avatarLoadingOverlay}>
|
||||||
|
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -356,12 +363,7 @@ function FieldLabel({ text }: { text: string }) {
|
|||||||
|
|
||||||
// 单位切换组件已移除(固定 kg/cm)
|
// 单位切换组件已移除(固定 kg/cm)
|
||||||
|
|
||||||
// 工具函数
|
|
||||||
// 转换函数不再使用,保留 round
|
|
||||||
function kgToLb(kg: number) { return kg * 2.2046226218; }
|
|
||||||
function lbToKg(lb: number) { return lb / 2.2046226218; }
|
|
||||||
function cmToFt(cm: number) { return cm / 30.48; }
|
|
||||||
function ftToCm(ft: number) { return ft * 30.48; }
|
|
||||||
function round(n: number, d = 0) { const p = Math.pow(10, d); return Math.round(n * p) / p; }
|
function round(n: number, d = 0) { const p = Math.pow(10, d); return Math.round(n * p) / p; }
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
@@ -390,6 +392,17 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||||
},
|
},
|
||||||
|
avatarLoadingOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
borderRadius: 60,
|
||||||
|
},
|
||||||
inputWrapper: {
|
inputWrapper: {
|
||||||
height: 52,
|
height: 52,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
|
|||||||
602
app/training-plan/create.tsx
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Modal, Platform, Pressable, SafeAreaView, ScrollView, StyleSheet, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { palette } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import {
|
||||||
|
clearError,
|
||||||
|
loadPlans,
|
||||||
|
saveDraftAsPlan,
|
||||||
|
setGoal,
|
||||||
|
setMode,
|
||||||
|
setName,
|
||||||
|
setPreferredTime,
|
||||||
|
setSessionsPerWeek,
|
||||||
|
setStartDate,
|
||||||
|
setStartDateNextMonday,
|
||||||
|
setStartWeight,
|
||||||
|
toggleDayOfWeek,
|
||||||
|
type PlanGoal
|
||||||
|
} from '@/store/trainingPlanSlice';
|
||||||
|
|
||||||
|
const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||||
|
const GOALS: { key: PlanGoal; title: string; desc: string }[] = [
|
||||||
|
{ key: 'postpartum_recovery', title: '产后恢复', desc: '温和激活,核心重建' },
|
||||||
|
{ key: 'posture_correction', title: '体态矫正', desc: '打开胸肩,改善圆肩驼背' },
|
||||||
|
{ key: 'fat_loss', title: '减脂塑形', desc: '全身燃脂,线条雕刻' },
|
||||||
|
{ key: 'core_strength', title: '核心力量', desc: '核心稳定,提升运动表现' },
|
||||||
|
{ key: 'flexibility', title: '柔韧灵活', desc: '拉伸延展,释放紧张' },
|
||||||
|
{ key: 'rehab', title: '康复保健', desc: '循序渐进,科学修复' },
|
||||||
|
{ key: 'stress_relief', title: '释压放松', desc: '舒缓身心,改善睡眠' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TrainingPlanCreateScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { draft, loading, error } = useAppSelector((s) => s.trainingPlan);
|
||||||
|
const [weightInput, setWeightInput] = useState<string>('');
|
||||||
|
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||||
|
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(loadPlans());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (draft.startWeightKg && !weightInput) setWeightInput(String(draft.startWeightKg));
|
||||||
|
}, [draft.startWeightKg]);
|
||||||
|
|
||||||
|
const selectedCount = draft.mode === 'daysOfWeek' ? draft.daysOfWeek.length : draft.sessionsPerWeek;
|
||||||
|
|
||||||
|
const canSave = useMemo(() => {
|
||||||
|
if (!draft.goal) return false;
|
||||||
|
if (draft.mode === 'daysOfWeek' && draft.daysOfWeek.length === 0) return false;
|
||||||
|
if (draft.mode === 'sessionsPerWeek' && draft.sessionsPerWeek <= 0) return false;
|
||||||
|
return true;
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
const formattedStartDate = useMemo(() => {
|
||||||
|
const d = new Date(draft.startDate);
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'short',
|
||||||
|
}).format(d);
|
||||||
|
} catch {
|
||||||
|
return d.toLocaleDateString('zh-CN');
|
||||||
|
}
|
||||||
|
}, [draft.startDate]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await dispatch(saveDraftAsPlan()).unwrap();
|
||||||
|
router.back();
|
||||||
|
} catch (error) {
|
||||||
|
// 错误已经在Redux中处理,这里可以显示额外的用户反馈
|
||||||
|
console.error('保存训练计划失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
// 3秒后自动清除错误
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
dispatch(clearError());
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [error, dispatch]);
|
||||||
|
|
||||||
|
const openDatePicker = () => {
|
||||||
|
const base = draft.startDate ? new Date(draft.startDate) : 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;
|
||||||
|
dispatch(setStartDate(finalDate.toISOString()));
|
||||||
|
closeDatePicker();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<HeaderBar title="新建训练计划" onBack={() => router.back()} withSafeTop={false} transparent />
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
|
||||||
|
<ThemedText style={styles.title}>制定你的训练计划</ThemedText>
|
||||||
|
<ThemedText style={styles.subtitle}>选择你的训练节奏与目标,我们将为你生成合适的普拉提安排。</ThemedText>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<ThemedText style={styles.errorText}>⚠️ {error}</ThemedText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<ThemedText style={styles.cardTitle}>计划名称</ThemedText>
|
||||||
|
<TextInput
|
||||||
|
placeholder="为你的训练计划起个名字(可选)"
|
||||||
|
value={draft.name || ''}
|
||||||
|
onChangeText={(text) => dispatch(setName(text))}
|
||||||
|
style={styles.nameInput}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<ThemedText style={styles.cardTitle}>训练频率</ThemedText>
|
||||||
|
<View style={styles.segment}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => dispatch(setMode('daysOfWeek'))}
|
||||||
|
style={[styles.segmentItem, draft.mode === 'daysOfWeek' && styles.segmentItemActive]}
|
||||||
|
>
|
||||||
|
<ThemedText style={[styles.segmentText, draft.mode === 'daysOfWeek' && styles.segmentTextActive]}>按星期选择</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => dispatch(setMode('sessionsPerWeek'))}
|
||||||
|
style={[styles.segmentItem, draft.mode === 'sessionsPerWeek' && styles.segmentItemActive]}
|
||||||
|
>
|
||||||
|
<ThemedText style={[styles.segmentText, draft.mode === 'sessionsPerWeek' && styles.segmentTextActive]}>每周次数</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{draft.mode === 'daysOfWeek' ? (
|
||||||
|
<View style={styles.weekRow}>
|
||||||
|
{WEEK_DAYS.map((d, i) => {
|
||||||
|
const active = draft.daysOfWeek.includes(i);
|
||||||
|
return (
|
||||||
|
<Pressable key={i} onPress={() => dispatch(toggleDayOfWeek(i))} style={[styles.dayChip, active && styles.dayChipActive]}>
|
||||||
|
<ThemedText style={[styles.dayChipText, active && styles.dayChipTextActive]}>{d}</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.sliderRow}>
|
||||||
|
<ThemedText style={styles.sliderLabel}>每周训练</ThemedText>
|
||||||
|
<View style={styles.counter}>
|
||||||
|
<Pressable onPress={() => dispatch(setSessionsPerWeek(Math.max(1, draft.sessionsPerWeek - 1)))} style={styles.counterBtn}>
|
||||||
|
<ThemedText style={styles.counterBtnText}>-</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
<ThemedText style={styles.counterValue}>{draft.sessionsPerWeek}</ThemedText>
|
||||||
|
<Pressable onPress={() => dispatch(setSessionsPerWeek(Math.min(7, draft.sessionsPerWeek + 1)))} style={styles.counterBtn}>
|
||||||
|
<ThemedText style={styles.counterBtnText}>+</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<ThemedText style={styles.sliderSuffix}>次</ThemedText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ThemedText style={styles.helper}>已选择:{selectedCount} 次/周</ThemedText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<ThemedText style={styles.cardTitle}>训练目标</ThemedText>
|
||||||
|
<View style={styles.goalGrid}>
|
||||||
|
{GOALS.map((g) => {
|
||||||
|
const active = draft.goal === g.key;
|
||||||
|
return (
|
||||||
|
<Pressable key={g.key} onPress={() => dispatch(setGoal(g.key))} style={[styles.goalItem, active && styles.goalItemActive]}>
|
||||||
|
<ThemedText style={[styles.goalTitle, active && styles.goalTitleActive]}>{g.title}</ThemedText>
|
||||||
|
<ThemedText style={styles.goalDesc}>{g.desc}</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<ThemedText style={styles.cardTitle}>更多选项</ThemedText>
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<ThemedText style={styles.label}>开始日期</ThemedText>
|
||||||
|
<View style={styles.rowRight}>
|
||||||
|
<Pressable onPress={openDatePicker} style={styles.linkBtn}>
|
||||||
|
<ThemedText style={styles.linkText}>选择日期</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => dispatch(setStartDateNextMonday())} style={[styles.linkBtn, { marginLeft: 8 }]}>
|
||||||
|
<ThemedText style={styles.linkText}>下周一</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ThemedText style={styles.dateHint}>{formattedStartDate}</ThemedText>
|
||||||
|
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<ThemedText style={styles.label}>开始体重 (kg)</ThemedText>
|
||||||
|
<TextInput
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="可选"
|
||||||
|
value={weightInput}
|
||||||
|
onChangeText={(t) => {
|
||||||
|
setWeightInput(t);
|
||||||
|
const v = Number(t);
|
||||||
|
dispatch(setStartWeight(Number.isFinite(v) ? v : undefined));
|
||||||
|
}}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.rowBetween, { marginTop: 12 }]}>
|
||||||
|
<ThemedText style={styles.label}>偏好时间段</ThemedText>
|
||||||
|
<View style={styles.segmentSmall}>
|
||||||
|
{(['morning', 'noon', 'evening', ''] as const).map((k) => (
|
||||||
|
<Pressable key={k || 'none'} onPress={() => dispatch(setPreferredTime(k))} style={[styles.segmentItemSmall, draft.preferredTimeOfDay === k && styles.segmentItemActiveSmall]}>
|
||||||
|
<ThemedText style={[styles.segmentTextSmall, draft.preferredTimeOfDay === k && styles.segmentTextActiveSmall]}>{k === 'morning' ? '晨练' : k === 'noon' ? '午间' : k === 'evening' ? '晚间' : '不限'}</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable disabled={!canSave || loading} onPress={handleSave} style={[styles.primaryBtn, (!canSave || loading) && styles.primaryBtnDisabled]}>
|
||||||
|
<ThemedText style={styles.primaryBtnText}>
|
||||||
|
{loading ? '创建中...' : canSave ? '生成计划' : '请先选择目标/频率'}
|
||||||
|
</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={{ height: 32 }} />
|
||||||
|
</ScrollView>
|
||||||
|
</ThemedView>
|
||||||
|
<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()}
|
||||||
|
{...(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]}>
|
||||||
|
<ThemedText style={styles.modalBtnText}>取消</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||||
|
<ThemedText style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F7F8FA',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F7F8FA',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
lineHeight: 36,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#5E6468',
|
||||||
|
marginTop: 6,
|
||||||
|
marginBottom: 16,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginTop: 14,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 12,
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0F172A',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
segment: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
segmentItem: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingVertical: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
segmentItemActive: {
|
||||||
|
backgroundColor: palette.primary,
|
||||||
|
},
|
||||||
|
segmentText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#475569',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
segmentTextActive: {
|
||||||
|
color: palette.ink,
|
||||||
|
},
|
||||||
|
weekRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 14,
|
||||||
|
},
|
||||||
|
dayChip: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
dayChipActive: {
|
||||||
|
backgroundColor: '#E0F8A2',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: palette.primary,
|
||||||
|
},
|
||||||
|
dayChipText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#334155',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
dayChipTextActive: {
|
||||||
|
color: '#0F172A',
|
||||||
|
},
|
||||||
|
sliderRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
sliderLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#334155',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
counter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
counterBtn: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
counterBtnText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#0F172A',
|
||||||
|
},
|
||||||
|
counterValue: {
|
||||||
|
width: 44,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#0F172A',
|
||||||
|
},
|
||||||
|
sliderSuffix: {
|
||||||
|
marginLeft: 8,
|
||||||
|
color: '#475569',
|
||||||
|
},
|
||||||
|
helper: {
|
||||||
|
marginTop: 10,
|
||||||
|
color: '#5E6468',
|
||||||
|
},
|
||||||
|
goalGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
goalItem: {
|
||||||
|
width: '48%',
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
goalItemActive: {
|
||||||
|
backgroundColor: '#E0F8A2',
|
||||||
|
borderColor: palette.primary,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
goalTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#0F172A',
|
||||||
|
},
|
||||||
|
goalTitleActive: {
|
||||||
|
color: '#0F172A',
|
||||||
|
},
|
||||||
|
goalDesc: {
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#5E6468',
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
rowBetween: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
rowRight: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#0F172A',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
linkBtn: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
color: '#334155',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
dateHint: {
|
||||||
|
marginTop: 6,
|
||||||
|
color: '#5E6468',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
marginLeft: 12,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
minWidth: 88,
|
||||||
|
textAlign: 'right',
|
||||||
|
color: '#0F172A',
|
||||||
|
},
|
||||||
|
segmentSmall: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
padding: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
segmentItemSmall: {
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
marginHorizontal: 3,
|
||||||
|
},
|
||||||
|
segmentItemActiveSmall: {
|
||||||
|
backgroundColor: palette.primary,
|
||||||
|
},
|
||||||
|
segmentTextSmall: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#475569',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
segmentTextActiveSmall: {
|
||||||
|
color: palette.ink,
|
||||||
|
},
|
||||||
|
primaryBtn: {
|
||||||
|
marginTop: 18,
|
||||||
|
backgroundColor: palette.primary,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
primaryBtnDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
primaryBtnText: {
|
||||||
|
color: palette.ink,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
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: palette.ink,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 计划名称输入框
|
||||||
|
nameInput: {
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#0F172A',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 错误状态
|
||||||
|
errorContainer: {
|
||||||
|
backgroundColor: 'rgba(237,71,71,0.1)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(237,71,71,0.2)',
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#ED4747',
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 58 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images": [
|
"images": [
|
||||||
{
|
{
|
||||||
"filename": "App-Icon-1024x1024@1x.png",
|
"filename": "logo.jpeg",
|
||||||
"idiom": "universal",
|
"idiom": "universal",
|
||||||
"platform": "ios",
|
"platform": "ios",
|
||||||
"size": "1024x1024"
|
"size": "1024x1024"
|
||||||
|
|||||||
BIN
ios/digitalpilates/Images.xcassets/AppIcon.appiconset/logo.jpeg
Normal file
|
After Width: | Height: | Size: 169 KiB |
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "logo.jpeg",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"filename": "image.png",
|
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "logo 1.jpeg",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"filename": "image@2x.png",
|
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "logo 2.jpeg",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"filename": "image@3x.png",
|
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
"version": 1,
|
"author" : "xcode",
|
||||||
"author": "expo"
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 51 KiB |
BIN
ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 1.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 2.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 169 KiB |
62
services/trainingPlanApi.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
export interface CreateTrainingPlanDto {
|
||||||
|
startDate: string;
|
||||||
|
name?: string;
|
||||||
|
mode: 'daysOfWeek' | 'sessionsPerWeek';
|
||||||
|
daysOfWeek: number[];
|
||||||
|
sessionsPerWeek: number;
|
||||||
|
goal: string;
|
||||||
|
startWeightKg?: number;
|
||||||
|
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainingPlanResponse {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
startDate: string;
|
||||||
|
mode: 'daysOfWeek' | 'sessionsPerWeek';
|
||||||
|
daysOfWeek: number[];
|
||||||
|
sessionsPerWeek: number;
|
||||||
|
goal: string;
|
||||||
|
startWeightKg: number | null;
|
||||||
|
preferredTimeOfDay: 'morning' | 'noon' | 'evening' | '';
|
||||||
|
updatedAt: string;
|
||||||
|
deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainingPlanSummary {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
startDate: string;
|
||||||
|
goal: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainingPlanListResponse {
|
||||||
|
list: TrainingPlanSummary[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TrainingPlanApi {
|
||||||
|
async create(dto: CreateTrainingPlanDto): Promise<TrainingPlanResponse> {
|
||||||
|
return api.post<TrainingPlanResponse>('/training-plans', dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(page: number = 1, limit: number = 10): Promise<TrainingPlanListResponse> {
|
||||||
|
return api.get<TrainingPlanListResponse>(`/training-plans?page=${page}&limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: string): Promise<TrainingPlanResponse> {
|
||||||
|
return api.get<TrainingPlanResponse>(`/training-plans/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<{ success: boolean }> {
|
||||||
|
return api.delete<{ success: boolean }>(`/training-plans/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trainingPlanApi = new TrainingPlanApi();
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CreateTrainingPlanDto, trainingPlanApi } 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';
|
||||||
|
|
||||||
@@ -22,14 +23,19 @@ export type TrainingPlan = {
|
|||||||
goal: PlanGoal | '';
|
goal: PlanGoal | '';
|
||||||
startWeightKg?: number;
|
startWeightKg?: number;
|
||||||
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
|
preferredTimeOfDay?: 'morning' | 'noon' | 'evening' | '';
|
||||||
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TrainingPlanState = {
|
export type TrainingPlanState = {
|
||||||
current?: TrainingPlan | null;
|
plans: TrainingPlan[];
|
||||||
|
currentId?: string | null;
|
||||||
draft: Omit<TrainingPlan, 'id' | 'createdAt'>;
|
draft: Omit<TrainingPlan, 'id' | 'createdAt'>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = '@training_plan';
|
const STORAGE_KEY_LEGACY_SINGLE = '@training_plan';
|
||||||
|
const STORAGE_KEY_LIST = '@training_plans';
|
||||||
|
|
||||||
function nextMondayISO(): string {
|
function nextMondayISO(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -42,7 +48,10 @@ function nextMondayISO(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialState: TrainingPlanState = {
|
const initialState: TrainingPlanState = {
|
||||||
current: null,
|
plans: [],
|
||||||
|
currentId: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
draft: {
|
draft: {
|
||||||
startDate: new Date(new Date().setHours(0, 0, 0, 0)).toISOString(),
|
startDate: new Date(new Date().setHours(0, 0, 0, 0)).toISOString(),
|
||||||
mode: 'daysOfWeek',
|
mode: 'daysOfWeek',
|
||||||
@@ -51,31 +60,141 @@ const initialState: TrainingPlanState = {
|
|||||||
goal: '',
|
goal: '',
|
||||||
startWeightKg: undefined,
|
startWeightKg: undefined,
|
||||||
preferredTimeOfDay: '',
|
preferredTimeOfDay: '',
|
||||||
|
name: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadTrainingPlan = createAsyncThunk('trainingPlan/load', async () => {
|
/**
|
||||||
const str = await AsyncStorage.getItem(STORAGE_KEY);
|
* 从服务器加载训练计划列表,同时支持本地缓存迁移
|
||||||
if (!str) return null;
|
*/
|
||||||
|
export const loadPlans = createAsyncThunk('trainingPlan/loadPlans', async (_, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(str) as TrainingPlan;
|
// 尝试从服务器获取数据
|
||||||
|
const response = await trainingPlanApi.list(1, 100); // 获取所有计划
|
||||||
|
console.log('response', response);
|
||||||
|
const plans: TrainingPlan[] = response.list.map(summary => ({
|
||||||
|
id: summary.id,
|
||||||
|
createdAt: summary.createdAt,
|
||||||
|
startDate: summary.startDate,
|
||||||
|
goal: summary.goal as PlanGoal,
|
||||||
|
mode: 'daysOfWeek', // 默认值,需要从详情获取
|
||||||
|
daysOfWeek: [],
|
||||||
|
sessionsPerWeek: 3,
|
||||||
|
preferredTimeOfDay: '',
|
||||||
|
name: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 读取最后一次使用的 currentId(从本地存储)
|
||||||
|
const currentId = (await AsyncStorage.getItem(`${STORAGE_KEY_LIST}__currentId`)) || null;
|
||||||
|
|
||||||
|
return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null };
|
||||||
|
} catch (error: any) {
|
||||||
|
// 如果API调用失败,回退到本地存储
|
||||||
|
console.warn('API调用失败,使用本地存储:', error.message);
|
||||||
|
|
||||||
|
// 新版:列表
|
||||||
|
const listStr = await AsyncStorage.getItem(STORAGE_KEY_LIST);
|
||||||
|
if (listStr) {
|
||||||
|
try {
|
||||||
|
const plans = JSON.parse(listStr) as TrainingPlan[];
|
||||||
|
const currentId = (await AsyncStorage.getItem(`${STORAGE_KEY_LIST}__currentId`)) || null;
|
||||||
|
return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
// 解析失败则视为无数据
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旧版:单计划
|
||||||
|
const legacyStr = await AsyncStorage.getItem(STORAGE_KEY_LEGACY_SINGLE);
|
||||||
|
if (legacyStr) {
|
||||||
|
try {
|
||||||
|
const legacy = JSON.parse(legacyStr) as TrainingPlan;
|
||||||
|
const plans = [legacy];
|
||||||
|
const currentId = legacy.id;
|
||||||
|
return { plans, currentId } as { plans: TrainingPlan[]; currentId: string | null };
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { plans: [], currentId: null } as { plans: TrainingPlan[]; currentId: string | null };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const saveTrainingPlan = createAsyncThunk(
|
/**
|
||||||
'trainingPlan/save',
|
* 将当前 draft 保存为新计划并设为当前计划。
|
||||||
async (_: void, { getState }) => {
|
*/
|
||||||
|
export const saveDraftAsPlan = createAsyncThunk(
|
||||||
|
'trainingPlan/saveDraftAsPlan',
|
||||||
|
async (_: void, { getState, rejectWithValue }) => {
|
||||||
|
try {
|
||||||
const s = (getState() as any).trainingPlan as TrainingPlanState;
|
const s = (getState() as any).trainingPlan as TrainingPlanState;
|
||||||
const draft = s.draft;
|
const draft = s.draft;
|
||||||
const plan: TrainingPlan = {
|
|
||||||
id: `plan_${Date.now()}`,
|
const createDto: CreateTrainingPlanDto = {
|
||||||
createdAt: new Date().toISOString(),
|
startDate: draft.startDate,
|
||||||
...draft,
|
name: draft.name,
|
||||||
|
mode: draft.mode,
|
||||||
|
daysOfWeek: draft.daysOfWeek,
|
||||||
|
sessionsPerWeek: draft.sessionsPerWeek,
|
||||||
|
goal: draft.goal,
|
||||||
|
startWeightKg: draft.startWeightKg,
|
||||||
|
preferredTimeOfDay: draft.preferredTimeOfDay,
|
||||||
};
|
};
|
||||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(plan));
|
|
||||||
return plan;
|
const response = await trainingPlanApi.create(createDto);
|
||||||
|
|
||||||
|
const plan: TrainingPlan = {
|
||||||
|
id: response.id,
|
||||||
|
createdAt: response.createdAt,
|
||||||
|
startDate: response.startDate,
|
||||||
|
mode: response.mode,
|
||||||
|
daysOfWeek: response.daysOfWeek,
|
||||||
|
sessionsPerWeek: response.sessionsPerWeek,
|
||||||
|
goal: response.goal as PlanGoal,
|
||||||
|
startWeightKg: response.startWeightKg || undefined,
|
||||||
|
preferredTimeOfDay: response.preferredTimeOfDay,
|
||||||
|
name: response.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextPlans = [...(s.plans || []), plan];
|
||||||
|
|
||||||
|
// 同时保存到本地存储作为缓存
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans));
|
||||||
|
await AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, plan.id);
|
||||||
|
|
||||||
|
return { plans: nextPlans, currentId: plan.id } as { plans: TrainingPlan[]; currentId: string };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '创建训练计划失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 删除计划 */
|
||||||
|
export const deletePlan = createAsyncThunk(
|
||||||
|
'trainingPlan/deletePlan',
|
||||||
|
async (planId: string, { getState, rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const s = (getState() as any).trainingPlan as TrainingPlanState;
|
||||||
|
|
||||||
|
// 调用API删除
|
||||||
|
await trainingPlanApi.delete(planId);
|
||||||
|
|
||||||
|
// 更新本地状态
|
||||||
|
const nextPlans = (s.plans || []).filter((p) => p.id !== planId);
|
||||||
|
let nextCurrentId = s.currentId || null;
|
||||||
|
if (nextCurrentId === planId) {
|
||||||
|
nextCurrentId = nextPlans.length > 0 ? nextPlans[nextPlans.length - 1].id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同时更新本地存储
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY_LIST, JSON.stringify(nextPlans));
|
||||||
|
await AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, nextCurrentId ?? '');
|
||||||
|
|
||||||
|
return { plans: nextPlans, currentId: nextCurrentId } as { plans: TrainingPlan[]; currentId: string | null };
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.message || '删除训练计划失败');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -83,6 +202,11 @@ const trainingPlanSlice = createSlice({
|
|||||||
name: 'trainingPlan',
|
name: 'trainingPlan',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
|
setCurrentPlan(state, action: PayloadAction<string | null | undefined>) {
|
||||||
|
state.currentId = action.payload ?? null;
|
||||||
|
// 保存到本地存储
|
||||||
|
AsyncStorage.setItem(`${STORAGE_KEY_LIST}__currentId`, action.payload ?? '');
|
||||||
|
},
|
||||||
setMode(state, action: PayloadAction<PlanMode>) {
|
setMode(state, action: PayloadAction<PlanMode>) {
|
||||||
state.draft.mode = action.payload;
|
state.draft.mode = action.payload;
|
||||||
},
|
},
|
||||||
@@ -108,30 +232,74 @@ const trainingPlanSlice = createSlice({
|
|||||||
setPreferredTime(state, action: PayloadAction<TrainingPlan['preferredTimeOfDay']>) {
|
setPreferredTime(state, action: PayloadAction<TrainingPlan['preferredTimeOfDay']>) {
|
||||||
state.draft.preferredTimeOfDay = action.payload;
|
state.draft.preferredTimeOfDay = action.payload;
|
||||||
},
|
},
|
||||||
|
setName(state, action: PayloadAction<string>) {
|
||||||
|
state.draft.name = action.payload;
|
||||||
|
},
|
||||||
setStartDateNextMonday(state) {
|
setStartDateNextMonday(state) {
|
||||||
state.draft.startDate = nextMondayISO();
|
state.draft.startDate = nextMondayISO();
|
||||||
},
|
},
|
||||||
resetDraft(state) {
|
resetDraft(state) {
|
||||||
state.draft = initialState.draft;
|
state.draft = initialState.draft;
|
||||||
},
|
},
|
||||||
|
clearError(state) {
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder
|
builder
|
||||||
.addCase(loadTrainingPlan.fulfilled, (state, action) => {
|
// loadPlans
|
||||||
state.current = action.payload;
|
.addCase(loadPlans.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(loadPlans.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.plans = action.payload.plans;
|
||||||
|
state.currentId = action.payload.currentId;
|
||||||
// 若存在历史计划,初始化 draft 基于该计划(便于编辑)
|
// 若存在历史计划,初始化 draft 基于该计划(便于编辑)
|
||||||
if (action.payload) {
|
const current = state.plans.find((p) => p.id === state.currentId) || state.plans[state.plans.length - 1];
|
||||||
const { id, createdAt, ...rest } = action.payload;
|
if (current) {
|
||||||
|
const { id, createdAt, ...rest } = current;
|
||||||
state.draft = { ...rest };
|
state.draft = { ...rest };
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.addCase(saveTrainingPlan.fulfilled, (state, action) => {
|
.addCase(loadPlans.rejected, (state, action) => {
|
||||||
state.current = action.payload;
|
state.loading = false;
|
||||||
|
state.error = action.payload as string || '加载训练计划失败';
|
||||||
|
})
|
||||||
|
// saveDraftAsPlan
|
||||||
|
.addCase(saveDraftAsPlan.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(saveDraftAsPlan.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.plans = action.payload.plans;
|
||||||
|
state.currentId = action.payload.currentId;
|
||||||
|
})
|
||||||
|
.addCase(saveDraftAsPlan.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string || '创建训练计划失败';
|
||||||
|
})
|
||||||
|
// deletePlan
|
||||||
|
.addCase(deletePlan.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(deletePlan.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.plans = action.payload.plans;
|
||||||
|
state.currentId = action.payload.currentId;
|
||||||
|
})
|
||||||
|
.addCase(deletePlan.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string || '删除训练计划失败';
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
|
setCurrentPlan,
|
||||||
setMode,
|
setMode,
|
||||||
toggleDayOfWeek,
|
toggleDayOfWeek,
|
||||||
setSessionsPerWeek,
|
setSessionsPerWeek,
|
||||||
@@ -139,8 +307,10 @@ export const {
|
|||||||
setStartWeight,
|
setStartWeight,
|
||||||
setStartDate,
|
setStartDate,
|
||||||
setPreferredTime,
|
setPreferredTime,
|
||||||
|
setName,
|
||||||
setStartDateNextMonday,
|
setStartDateNextMonday,
|
||||||
resetDraft,
|
resetDraft,
|
||||||
|
clearError,
|
||||||
} = trainingPlanSlice.actions;
|
} = trainingPlanSlice.actions;
|
||||||
|
|
||||||
export default trainingPlanSlice.reducer;
|
export default trainingPlanSlice.reducer;
|
||||||
|
|||||||