Files
digital-pilates/app/profile/edit.tsx
richarjiang 8ffebfb297 feat: 更新健康数据功能和用户个人信息页面
- 在 Explore 页面中添加日期选择功能,允许用户查看指定日期的健康数据
- 重构健康数据获取逻辑,支持根据日期获取健康数据
- 在个人信息页面中集成用户资料编辑功能,支持姓名、性别、年龄、体重和身高的输入
- 新增 AnimatedNumber 和 CircularRing 组件,优化数据展示效果
- 更新 package.json 和 package-lock.json,添加 react-native-svg 依赖
- 修改布局以支持新功能的显示和交互
2025-08-12 18:54:15 +08:00

455 lines
15 KiB
TypeScript

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, useMemo, 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;
unitPref?: {
weight: WeightUnit;
height: HeightUnit;
};
}
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,
unitPref: { weight: 'kg', height: 'cm' },
});
const [weightInput, setWeightInput] = useState<string>('');
const [heightInput, setHeightInput] = useState<string>('');
// 将存储的公制值转换为当前选择单位显示
const displayedWeight = useMemo(() => {
if (profile.weightKg == null || isNaN(profile.weightKg)) return '';
return profile.unitPref?.weight === 'kg'
? String(round(profile.weightKg, 1))
: String(round(kgToLb(profile.weightKg), 1));
}, [profile.weightKg, profile.unitPref?.weight]);
const displayedHeight = useMemo(() => {
if (profile.heightCm == null || isNaN(profile.heightCm)) return '';
return profile.unitPref?.height === 'cm'
? String(Math.round(profile.heightCm))
: String(round(cmToFt(profile.heightCm), 1));
}, [profile.heightCm, profile.unitPref?.height]);
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,
unitPref: { weight: 'kg', height: 'cm' },
};
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 ? (next.unitPref?.weight === 'kg' ? String(round(next.weightKg, 1)) : String(round(kgToLb(next.weightKg), 1))) : '');
setHeightInput(next.heightCm != null ? (next.unitPref?.height === 'cm' ? String(Math.round(next.heightCm)) : String(round(cmToFt(next.heightCm), 1))) : '');
} catch (e) {
console.warn('读取资料失败', e);
}
})();
}, []);
const textColor = colors.text;
const placeholderColor = colors.icon;
const handleSave = async () => {
try {
const next: UserProfile = { ...profile };
// 将当前输入反向同步为公制
const w = parseFloat(weightInput);
if (!isNaN(w)) {
next.weightKg = profile.unitPref?.weight === 'kg' ? w : lbToKg(w);
} else {
next.weightKg = undefined;
}
const h = parseFloat(heightInput);
if (!isNaN(h)) {
next.heightCm = profile.unitPref?.height === 'cm' ? h : ftToCm(h);
} else {
next.heightCm = undefined;
}
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
Alert.alert('已保存', '个人资料已更新。');
router.back();
} catch (e) {
Alert.alert('保存失败', '请稍后重试');
}
};
const toggleWeightUnit = (unit: WeightUnit) => {
if (unit === profile.unitPref?.weight) return;
const current = parseFloat(weightInput);
let nextValueStr = weightInput;
if (!isNaN(current)) {
nextValueStr = unit === 'kg' ? String(round(lbToKg(current), 1)) : String(round(kgToLb(current), 1));
}
setProfile((p) => ({ ...p, unitPref: { ...(p.unitPref || { weight: 'kg', height: 'cm' }), weight: unit } }));
setWeightInput(nextValueStr);
};
const toggleHeightUnit = (unit: HeightUnit) => {
if (unit === profile.unitPref?.height) return;
const current = parseFloat(heightInput);
let nextValueStr = heightInput;
if (!isNaN(current)) {
nextValueStr = unit === 'cm' ? String(Math.round(ftToCm(current))) : String(round(cmToFt(current), 1));
}
setProfile((p) => ({ ...p, unitPref: { ...(p.unitPref || { weight: 'kg', height: 'cm' }), height: unit } }));
setHeightInput(nextValueStr);
};
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}>
{/* 统一头部 */}
<View style={[styles.header]}>
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color="#192126" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<View style={{ width: 32 }} />
</View>
{/* 头像(带相机蒙层,点击从相册选择) */}
<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>
{/* 体重 */}
<FieldLabel text="体重" />
<View style={styles.row}>
<View style={[styles.inputWrapper, styles.flex1, { borderColor: '#E0E0E0' }]}>
<TextInput
style={[styles.textInput, { color: textColor }]}
placeholder={profile.unitPref?.weight === 'kg' ? '输入体重' : '输入体重'}
placeholderTextColor={placeholderColor}
keyboardType="numeric"
value={weightInput}
onChangeText={setWeightInput}
/>
</View>
<SegmentedTwo
options={[{ key: 'lb', label: 'LBS' }, { key: 'kg', label: 'KG' }]}
activeKey={profile.unitPref?.weight || 'kg'}
onChange={(key) => toggleWeightUnit(key as WeightUnit)}
/>
</View>
{/* 身高 */}
<FieldLabel text="身高" />
<View style={styles.row}>
<View style={[styles.inputWrapper, styles.flex1, { borderColor: '#E0E0E0' }]}>
<TextInput
style={[styles.textInput, { color: textColor }]}
placeholder={profile.unitPref?.height === 'cm' ? '输入身高' : '输入身高'}
placeholderTextColor={placeholderColor}
keyboardType="numeric"
value={heightInput}
onChangeText={setHeightInput}
/>
</View>
<SegmentedTwo
options={[{ key: 'ft', label: 'FEET' }, { key: 'cm', label: 'CM' }]}
activeKey={profile.unitPref?.height || 'cm'}
onChange={(key) => toggleHeightUnit(key as HeightUnit)}
/>
</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>
);
}
function SegmentedTwo(props: {
options: { key: string; label: string }[];
activeKey: string;
onChange: (key: string) => void;
}) {
const { options, activeKey, onChange } = props;
return (
<View style={[styles.segmented, { backgroundColor: '#EFEFEF' }]}>
{options.map((opt) => (
<TouchableOpacity
key={opt.key}
style={[styles.segmentBtn, activeKey === opt.key && { backgroundColor: '#FFFFFF' }]}
onPress={() => onChange(opt.key)}
activeOpacity={0.8}
>
<Text style={{ fontSize: 14, fontWeight: '600', color: activeKey === opt.key ? '#000' : '#5E6468' }}>{opt.label}</Text>
</TouchableOpacity>
))}
</View>
);
}
// 工具函数
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' },
});