- 新增训练计划页面,允许用户制定个性化的训练计划 - 集成打卡功能,用户可以记录每日的训练情况 - 更新 Redux 状态管理,添加训练计划相关的 reducer - 在首页中添加训练计划卡片,支持用户点击跳转 - 更新样式和布局,以适应新功能的展示和交互 - 添加日期选择器和相关依赖,支持用户选择训练日期
383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||
import * as ImagePicker from 'expo-image-picker';
|
||
import { router } from 'expo-router';
|
||
import React, { useEffect, useState } from 'react';
|
||
import {
|
||
Alert,
|
||
Image,
|
||
KeyboardAvoidingView,
|
||
Platform,
|
||
SafeAreaView,
|
||
ScrollView,
|
||
StatusBar,
|
||
StyleSheet,
|
||
Text,
|
||
TextInput,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
type WeightUnit = 'kg' | 'lb';
|
||
type HeightUnit = 'cm' | 'ft';
|
||
|
||
interface UserProfile {
|
||
fullName?: string;
|
||
gender?: 'male' | 'female' | '';
|
||
age?: string; // 存储为字符串,方便非必填
|
||
// 以公制为基准存储
|
||
weightKg?: number; // kg
|
||
heightCm?: number; // cm
|
||
avatarUri?: string | null;
|
||
}
|
||
|
||
const STORAGE_KEY = '@user_profile';
|
||
|
||
export default function EditProfileScreen() {
|
||
const colorScheme = useColorScheme();
|
||
const colors = Colors[colorScheme ?? 'light'];
|
||
const insets = useSafeAreaInsets();
|
||
|
||
const [profile, setProfile] = useState<UserProfile>({
|
||
fullName: '',
|
||
gender: '',
|
||
age: '',
|
||
weightKg: undefined,
|
||
heightCm: undefined,
|
||
avatarUri: null,
|
||
});
|
||
|
||
const [weightInput, setWeightInput] = useState<string>('');
|
||
const [heightInput, setHeightInput] = useState<string>('');
|
||
|
||
// 输入框字符串
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
// 读取已保存资料;兼容引导页的个人信息
|
||
const [p, fromOnboarding] = await Promise.all([
|
||
AsyncStorage.getItem(STORAGE_KEY),
|
||
AsyncStorage.getItem('@user_personal_info'),
|
||
]);
|
||
|
||
let next: UserProfile = {
|
||
fullName: '',
|
||
gender: '',
|
||
age: '',
|
||
weightKg: undefined,
|
||
heightCm: undefined,
|
||
avatarUri: null,
|
||
};
|
||
|
||
if (fromOnboarding) {
|
||
try {
|
||
const o = JSON.parse(fromOnboarding);
|
||
if (o?.weight) next.weightKg = parseFloat(o.weight) || undefined;
|
||
if (o?.height) next.heightCm = parseFloat(o.height) || undefined;
|
||
if (o?.age) next.age = String(o.age);
|
||
if (o?.gender) next.gender = o.gender;
|
||
} catch { }
|
||
}
|
||
|
||
if (p) {
|
||
try {
|
||
const parsed: UserProfile = JSON.parse(p);
|
||
next = { ...next, ...parsed };
|
||
} catch { }
|
||
}
|
||
setProfile(next);
|
||
setWeightInput(next.weightKg != null ? String(round(next.weightKg, 1)) : '');
|
||
setHeightInput(next.heightCm != null ? String(Math.round(next.heightCm)) : '');
|
||
} catch (e) {
|
||
console.warn('读取资料失败', e);
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
const textColor = colors.text;
|
||
const placeholderColor = colors.icon;
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
const next: UserProfile = { ...profile };
|
||
|
||
// 将当前输入同步为公制(固定 kg/cm)
|
||
const w = parseFloat(weightInput);
|
||
if (!isNaN(w)) {
|
||
next.weightKg = w;
|
||
} else {
|
||
next.weightKg = undefined;
|
||
}
|
||
|
||
const h = parseFloat(heightInput);
|
||
if (!isNaN(h)) {
|
||
next.heightCm = h;
|
||
} else {
|
||
next.heightCm = undefined;
|
||
}
|
||
|
||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||
Alert.alert('已保存', '个人资料已更新。');
|
||
router.back();
|
||
} catch (e) {
|
||
Alert.alert('保存失败', '请稍后重试');
|
||
}
|
||
};
|
||
|
||
// 不再需要单位切换
|
||
|
||
const pickAvatarFromLibrary = async () => {
|
||
try {
|
||
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
|
||
if (!libGranted) {
|
||
Alert.alert('权限不足', '需要相册权限以选择头像');
|
||
return;
|
||
}
|
||
const result = await ImagePicker.launchImageLibraryAsync({
|
||
allowsEditing: true,
|
||
quality: 0.9,
|
||
aspect: [1, 1],
|
||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||
});
|
||
if (!result.canceled) {
|
||
setProfile((p) => ({ ...p, avatarUri: result.assets?.[0]?.uri ?? null }));
|
||
}
|
||
} catch (e) {
|
||
Alert.alert('发生错误', '选择头像失败,请重试');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<SafeAreaView style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
|
||
<StatusBar barStyle={colorScheme === 'dark' ? 'light-content' : '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 />
|
||
|
||
{/* 头像(带相机蒙层,点击从相册选择) */}
|
||
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 16 }}>
|
||
<TouchableOpacity activeOpacity={0.85} onPress={pickAvatarFromLibrary}>
|
||
<View style={styles.avatarCircle}>
|
||
{profile.avatarUri ? (
|
||
<Image source={{ uri: profile.avatarUri }} style={styles.avatarImage} />
|
||
) : (
|
||
<View style={{ width: 56, height: 56, borderRadius: 28, backgroundColor: '#D4A574' }} />
|
||
)}
|
||
<View style={styles.avatarOverlay}>
|
||
<Ionicons name="camera" size={22} color="#192126" />
|
||
</View>
|
||
</View>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* 姓名 */}
|
||
<FieldLabel text="姓名" />
|
||
<View style={[styles.inputWrapper, { borderColor: '#E0E0E0' }]}>
|
||
<TextInput
|
||
style={[styles.textInput, { color: textColor }]}
|
||
placeholder="填写姓名(可选)"
|
||
placeholderTextColor={placeholderColor}
|
||
value={profile.fullName}
|
||
onChangeText={(t) => setProfile((p) => ({ ...p, fullName: t }))}
|
||
/>
|
||
{/* 校验勾无需强制,仅装饰 */}
|
||
{!!profile.fullName && <Text style={{ color: '#C4C4C4' }}>✓</Text>}
|
||
</View>
|
||
|
||
{/* 体重(kg) */}
|
||
<FieldLabel text="体重" />
|
||
<View style={styles.row}>
|
||
<View style={[styles.inputWrapper, styles.flex1, { borderColor: '#E0E0E0' }]}>
|
||
<TextInput
|
||
style={[styles.textInput, { color: textColor }]}
|
||
placeholder={'输入体重'}
|
||
placeholderTextColor={placeholderColor}
|
||
keyboardType="numeric"
|
||
value={weightInput}
|
||
onChangeText={setWeightInput}
|
||
/>
|
||
<Text style={{ color: '#5E6468', marginLeft: 6 }}>kg</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 身高(cm) */}
|
||
<FieldLabel text="身高" />
|
||
<View style={styles.row}>
|
||
<View style={[styles.inputWrapper, styles.flex1, { borderColor: '#E0E0E0' }]}>
|
||
<TextInput
|
||
style={[styles.textInput, { color: textColor }]}
|
||
placeholder={'输入身高'}
|
||
placeholderTextColor={placeholderColor}
|
||
keyboardType="numeric"
|
||
value={heightInput}
|
||
onChangeText={setHeightInput}
|
||
/>
|
||
<Text style={{ color: '#5E6468', marginLeft: 6 }}>cm</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 性别 */}
|
||
<FieldLabel text="性别" />
|
||
<View style={[styles.selector, { borderColor: '#E0E0E0' }]}>
|
||
<TouchableOpacity
|
||
style={[styles.selectorItem, profile.gender === 'female' && { backgroundColor: colors.primary + '40' }]}
|
||
onPress={() => setProfile((p) => ({ ...p, gender: 'female' }))}
|
||
>
|
||
<Text style={styles.selectorEmoji}>♀</Text>
|
||
<Text style={styles.selectorText}>女性</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={[styles.selectorItem, profile.gender === 'male' && { backgroundColor: colors.primary + '40' }]}
|
||
onPress={() => setProfile((p) => ({ ...p, gender: 'male' }))}
|
||
>
|
||
<Text style={styles.selectorEmoji}>♂</Text>
|
||
<Text style={styles.selectorText}>男性</Text>
|
||
</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>
|
||
|
||
{/* 保存按钮 */}
|
||
<TouchableOpacity onPress={handleSave} activeOpacity={0.9} style={[styles.saveBtn, { backgroundColor: colors.primary }]}>
|
||
<Text style={{ color: colors.onPrimary, fontSize: 18, fontWeight: '700' }}>保存</Text>
|
||
</TouchableOpacity>
|
||
</ScrollView>
|
||
</KeyboardAvoidingView>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
function FieldLabel({ text }: { text: string }) {
|
||
return (
|
||
<Text style={{ fontSize: 14, color: '#5E6468', marginBottom: 8, marginTop: 10 }}>{text}</Text>
|
||
);
|
||
}
|
||
|
||
// 单位切换组件已移除(固定 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; }
|
||
|
||
const styles = StyleSheet.create({
|
||
container: { flex: 1 },
|
||
avatarCircle: {
|
||
width: 120,
|
||
height: 120,
|
||
borderRadius: 60,
|
||
backgroundColor: '#E8D4F0',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginBottom: 12,
|
||
},
|
||
avatarImage: {
|
||
width: 120,
|
||
height: 120,
|
||
borderRadius: 60,
|
||
},
|
||
avatarOverlay: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||
},
|
||
inputWrapper: {
|
||
height: 52,
|
||
backgroundColor: '#fff',
|
||
borderRadius: 12,
|
||
borderWidth: 1,
|
||
paddingHorizontal: 16,
|
||
alignItems: 'center',
|
||
flexDirection: 'row',
|
||
},
|
||
textInput: {
|
||
flex: 1,
|
||
fontSize: 16,
|
||
},
|
||
row: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
},
|
||
flex1: { flex: 1 },
|
||
segmented: {
|
||
flexDirection: 'row',
|
||
borderRadius: 12,
|
||
padding: 4,
|
||
backgroundColor: '#EFEFEF',
|
||
},
|
||
segmentBtn: {
|
||
paddingVertical: 12,
|
||
paddingHorizontal: 16,
|
||
borderRadius: 10,
|
||
backgroundColor: 'transparent',
|
||
minWidth: 64,
|
||
alignItems: 'center',
|
||
},
|
||
selector: {
|
||
flexDirection: 'row',
|
||
gap: 12,
|
||
backgroundColor: '#FFFFFF',
|
||
borderWidth: 1,
|
||
borderRadius: 12,
|
||
padding: 8,
|
||
},
|
||
selectorItem: {
|
||
flex: 1,
|
||
height: 48,
|
||
borderRadius: 10,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
selectorEmoji: { fontSize: 16, marginBottom: 2 },
|
||
selectorText: { fontSize: 15, fontWeight: '600' },
|
||
saveBtn: {
|
||
marginTop: 24,
|
||
height: 56,
|
||
borderRadius: 16,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 2 },
|
||
shadowOpacity: 0.1,
|
||
shadowRadius: 4,
|
||
elevation: 4,
|
||
},
|
||
header: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: 0,
|
||
marginBottom: 8,
|
||
},
|
||
backButton: { padding: 4, width: 32 },
|
||
headerTitle: { fontSize: 18, fontWeight: '700', color: '#192126' },
|
||
});
|
||
|
||
|