Files
digital-pilates/app/profile/edit.tsx
richarjiang c3d4630801 feat: 添加用户登录和法律协议页面
- 新增登录页面,支持 Apple 登录和游客登录功能
- 添加用户协议和隐私政策页面,用户需同意后才能登录
- 更新首页逻辑,首次进入时自动跳转到登录页面
- 修改个人信息页面,移除单位选择功能,统一使用 kg 和 cm
- 更新依赖,添加 expo-apple-authentication 库以支持 Apple 登录
- 更新布局以适应新功能的展示和交互
2025-08-12 19:21:07 +08:00

389 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 { 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}>
{/* 统一头部 */}
<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>
{/* 体重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' },
});