Files
digital-pilates/app/profile/edit.tsx
richarjiang 5814044cee feat: 更新 AI 教练聊天界面和个人信息页面
- 在 AI 教练聊天界面中添加训练记录分析功能,允许用户基于近期训练记录获取分析建议
- 更新 Redux 状态管理,集成每日步数和卡路里目标
- 在个人信息页面中优化用户头像显示,支持从库中选择头像
- 修改首页布局,添加可拖动的教练徽章,提升用户交互体验
- 更新样式以适应新功能的展示和交互
2025-08-13 10:09:55 +08:00

379 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}>
<Image source={{ uri: profile.avatarUri || 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg' }} style={styles.avatarImage} />
<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' },
});