935 lines
30 KiB
TypeScript
935 lines
30 KiB
TypeScript
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
|
||
import { fetchMaximumHeartRate } from '@/utils/health';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||
import { Picker } from '@react-native-picker/picker';
|
||
import { useFocusEffect } from '@react-navigation/native';
|
||
import { Image } from 'expo-image';
|
||
import * as ImagePicker from 'expo-image-picker';
|
||
import { router } from 'expo-router';
|
||
import React, { useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
KeyboardAvoidingView,
|
||
Modal,
|
||
Platform,
|
||
Pressable,
|
||
SafeAreaView,
|
||
ScrollView,
|
||
StatusBar,
|
||
StyleSheet,
|
||
Text,
|
||
TextInput,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
|
||
|
||
interface UserProfile {
|
||
name?: string;
|
||
gender?: 'male' | 'female' | '';
|
||
birthDate?: string; // 出生日期
|
||
// 以公制为基准存储
|
||
weight?: number; // kg
|
||
height?: number; // cm
|
||
avatarUri?: string | null;
|
||
avatarBase64?: string | null; // 兼容旧逻辑(不再上报)
|
||
activityLevel?: number; // 活动水平 1-4
|
||
maxHeartRate?: number; // 最大心率
|
||
}
|
||
|
||
const STORAGE_KEY = '@user_profile';
|
||
|
||
export default function EditProfileScreen() {
|
||
const colorScheme = useColorScheme();
|
||
const colors = Colors[colorScheme ?? 'light'];
|
||
const dispatch = useAppDispatch();
|
||
const accountProfile = useAppSelector((s) => (s as any)?.user?.profile as any);
|
||
const userId: string | undefined = useMemo(() => {
|
||
return (
|
||
accountProfile?.userId ||
|
||
accountProfile?.id ||
|
||
accountProfile?._id ||
|
||
accountProfile?.uid ||
|
||
undefined
|
||
) as string | undefined;
|
||
}, [accountProfile]);
|
||
|
||
const [profile, setProfile] = useState<UserProfile>({
|
||
name: '',
|
||
gender: '',
|
||
birthDate: '',
|
||
weight: undefined,
|
||
height: undefined,
|
||
avatarUri: null,
|
||
activityLevel: undefined,
|
||
maxHeartRate: undefined,
|
||
});
|
||
|
||
// 出生日期选择器
|
||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||
const [editingField, setEditingField] = useState<string | null>(null);
|
||
const [tempValue, setTempValue] = useState<string>('');
|
||
|
||
// 输入框字符串
|
||
|
||
// 从本地存储加载(身高/体重等本地字段)
|
||
const loadLocalProfile = async () => {
|
||
try {
|
||
const [p, fromOnboarding] = await Promise.all([
|
||
AsyncStorage.getItem(STORAGE_KEY),
|
||
AsyncStorage.getItem('@user_personal_info'),
|
||
]);
|
||
let next: UserProfile = {
|
||
name: '',
|
||
gender: '',
|
||
birthDate: '',
|
||
weight: undefined,
|
||
height: undefined,
|
||
avatarUri: null,
|
||
activityLevel: undefined,
|
||
maxHeartRate: undefined,
|
||
};
|
||
if (fromOnboarding) {
|
||
try {
|
||
const o = JSON.parse(fromOnboarding);
|
||
|
||
if (o?.weight) next.weight = parseFloat(o.weight) || undefined;
|
||
if (o?.height) next.height = parseFloat(o.height) || undefined;
|
||
if (o?.birthDate) next.birthDate = o.birthDate;
|
||
if (o?.gender) next.gender = o.gender;
|
||
} catch { }
|
||
}
|
||
if (p) {
|
||
try {
|
||
const parsed: UserProfile = JSON.parse(p);
|
||
next = { ...next, ...parsed };
|
||
} catch { }
|
||
}
|
||
console.log('loadLocalProfile', next);
|
||
setProfile((prev) => ({ ...next, avatarUri: prev.avatarUri ?? next.avatarUri ?? null }));
|
||
|
||
} catch (e) {
|
||
console.warn('读取资料失败', e);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadLocalProfile();
|
||
}, []);
|
||
|
||
// 获取最大心率数据
|
||
useEffect(() => {
|
||
const loadMaximumHeartRate = async () => {
|
||
try {
|
||
const today = new Date();
|
||
const startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); // 过去7天
|
||
|
||
const maxHeartRate = await fetchMaximumHeartRate({
|
||
startDate: startDate.toISOString(),
|
||
endDate: today.toISOString(),
|
||
});
|
||
|
||
if (maxHeartRate !== null) {
|
||
setProfile(prev => ({ ...prev, maxHeartRate }));
|
||
}
|
||
} catch (error) {
|
||
console.warn('获取最大心率失败', error);
|
||
}
|
||
};
|
||
|
||
loadMaximumHeartRate();
|
||
}, []);
|
||
|
||
// 页面聚焦时拉取最新用户信息,并刷新本地 UI
|
||
useFocusEffect(
|
||
React.useCallback(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
await dispatch(fetchMyProfile() as any);
|
||
if (!cancelled) {
|
||
// 拉取完成后,再次从本地存储同步身高/体重等字段
|
||
await loadLocalProfile();
|
||
}
|
||
} catch { }
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [dispatch])
|
||
);
|
||
|
||
// 当全局 profile 更新时,用后端字段覆盖页面 UI 的对应字段(不影响本地身高/体重)
|
||
useEffect(() => {
|
||
if (!accountProfile) return;
|
||
setProfile((prev) => ({
|
||
...prev,
|
||
name: accountProfile?.name ?? prev.name ?? '',
|
||
gender: (accountProfile?.gender === 'male' || accountProfile?.gender === 'female') ? accountProfile.gender : (prev.gender ?? ''),
|
||
avatarUri: accountProfile?.avatar && typeof accountProfile.avatar === 'string'
|
||
? (accountProfile.avatar.startsWith('http') || accountProfile.avatar.startsWith('data:') ? accountProfile.avatar : prev.avatarUri)
|
||
: prev.avatarUri,
|
||
weight: accountProfile?.weight ?? prev.weight ?? undefined,
|
||
height: accountProfile?.height ?? prev.height ?? undefined,
|
||
activityLevel: accountProfile?.activityLevel ?? prev.activityLevel ?? undefined,
|
||
// maxHeartRate 不从后端获取,保持本地状态
|
||
maxHeartRate: prev.maxHeartRate,
|
||
}));
|
||
}, [accountProfile]);
|
||
|
||
const textColor = colors.text;
|
||
const placeholderColor = colors.icon;
|
||
|
||
const handleSaveWithProfile = async (profileData: UserProfile) => {
|
||
try {
|
||
if (!userId) {
|
||
Alert.alert('未登录', '请先登录后再尝试保存');
|
||
return;
|
||
}
|
||
const next: UserProfile = { ...profileData };
|
||
|
||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||
|
||
// 同步到后端(仅更新后端需要的字段)
|
||
try {
|
||
await dispatch(updateUserProfile({
|
||
name: next.name || undefined,
|
||
gender: (next.gender === 'male' || next.gender === 'female') ? next.gender : undefined,
|
||
// 头像采用已上传的 URL(若有)
|
||
avatar: next.avatarUri || undefined,
|
||
weight: next.weight || undefined,
|
||
height: next.height || undefined,
|
||
birthDate: next.birthDate ? new Date(next.birthDate).getTime() / 1000 : undefined,
|
||
activityLevel: next.activityLevel || undefined,
|
||
}));
|
||
// 拉取最新用户信息,刷新全局状态
|
||
await dispatch(fetchMyProfile() as any);
|
||
} catch (e: any) {
|
||
// 接口失败不阻断本地保存
|
||
console.warn('更新用户信息失败', e?.message || e);
|
||
}
|
||
} catch (e) {
|
||
Alert.alert('保存失败', '请稍后重试');
|
||
}
|
||
};
|
||
|
||
const { upload, uploading } = useCosUpload();
|
||
|
||
// 出生日期选择器交互
|
||
const openDatePicker = () => {
|
||
const base = profile.birthDate ? new Date(profile.birthDate) : new Date();
|
||
base.setHours(0, 0, 0, 0);
|
||
setPickerDate(base);
|
||
setDatePickerVisible(true);
|
||
};
|
||
const closeDatePicker = () => setDatePickerVisible(false);
|
||
const onConfirmDate = async (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;
|
||
|
||
const updatedProfile = { ...profile, birthDate: finalDate.toISOString() };
|
||
setProfile((p) => ({ ...p, birthDate: finalDate.toISOString() }));
|
||
closeDatePicker();
|
||
|
||
// 保存到后端
|
||
await handleSaveWithProfile(updatedProfile);
|
||
};
|
||
|
||
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: ['images'],
|
||
base64: false,
|
||
});
|
||
if (!result.canceled) {
|
||
const asset = result.assets?.[0];
|
||
if (!asset?.uri) return;
|
||
// 直接上传到 COS,成功后写入 URL
|
||
try {
|
||
const { url } = await upload(
|
||
{ uri: asset.uri, name: asset.fileName || 'avatar.jpg', type: asset.mimeType || 'image/jpeg' },
|
||
{ prefix: 'avatars/', userId }
|
||
);
|
||
|
||
setProfile((p) => ({ ...p, avatarUri: url, avatarBase64: null }));
|
||
} catch (e) {
|
||
console.warn('上传头像失败', e);
|
||
Alert.alert('上传失败', '头像上传失败,请重试');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
Alert.alert('发生错误', '选择头像失败,请重试');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<SafeAreaView style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
|
||
<StatusBar barStyle={'dark-content'} />
|
||
|
||
{/* HeaderBar 放在 ScrollView 外部,确保全宽显示 */}
|
||
<HeaderBar
|
||
title="编辑资料"
|
||
onBack={() => router.back()}
|
||
withSafeTop={false}
|
||
transparent={true}
|
||
variant="elevated"
|
||
/>
|
||
|
||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
|
||
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
|
||
|
||
{/* 头像(带相机蒙层,点击从相册选择) */}
|
||
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 32 }}>
|
||
<TouchableOpacity activeOpacity={0.85} onPress={pickAvatarFromLibrary} disabled={uploading}>
|
||
<View style={styles.avatarCircle}>
|
||
<Image source={{ uri: profile.avatarUri || 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg' }} style={styles.avatarImage} />
|
||
<View style={styles.avatarOverlay}>
|
||
<Ionicons name="camera" size={22} color="#192126" />
|
||
</View>
|
||
{uploading && (
|
||
<View style={styles.avatarLoadingOverlay}>
|
||
<ActivityIndicator size="large" color="#FFFFFF" />
|
||
</View>
|
||
)}
|
||
</View>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* 用户信息卡片列表 */}
|
||
<View style={styles.cardContainer}>
|
||
{/* 姓名 */}
|
||
<ProfileCard
|
||
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-name.png'
|
||
title="昵称"
|
||
value={profile.name || '今晚要吃肉'}
|
||
onPress={() => {
|
||
setTempValue(profile.name || '');
|
||
setEditingField('name');
|
||
}}
|
||
/>
|
||
|
||
{/* 性别 */}
|
||
<ProfileCard
|
||
icon="body"
|
||
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-sex.png"
|
||
iconColor="#FF6B9D"
|
||
title="性别"
|
||
value={profile.gender === 'male' ? '男' : profile.gender === 'female' ? '女' : '未设置'}
|
||
onPress={() => {
|
||
setEditingField('gender');
|
||
}}
|
||
/>
|
||
|
||
{/* 身高 */}
|
||
<ProfileCard
|
||
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-height.png"
|
||
title="身高"
|
||
value={profile.height ? `${Math.round(profile.height)}厘米` : '170厘米'}
|
||
onPress={() => {
|
||
setTempValue(profile.height ? String(Math.round(profile.height)) : '170');
|
||
setEditingField('height');
|
||
}}
|
||
/>
|
||
|
||
{/* 体重 */}
|
||
<ProfileCard
|
||
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-weight.png"
|
||
title="体重"
|
||
value={profile.weight ? `${round(profile.weight, 1)}公斤` : '55公斤'}
|
||
onPress={() => {
|
||
setTempValue(profile.weight ? String(round(profile.weight, 1)) : '55');
|
||
setEditingField('weight');
|
||
}}
|
||
/>
|
||
|
||
{/* 活动水平 */}
|
||
<ProfileCard
|
||
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-activity.png'
|
||
title="活动水平"
|
||
value={(() => {
|
||
switch (profile.activityLevel) {
|
||
case 1: return '久坐';
|
||
case 2: return '轻度活跃';
|
||
case 3: return '中度活跃';
|
||
case 4: return '非常活跃';
|
||
default: return '久坐';
|
||
}
|
||
})()}
|
||
onPress={() => {
|
||
setEditingField('activity');
|
||
}}
|
||
/>
|
||
|
||
{/* 出生日期 */}
|
||
<ProfileCard
|
||
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-birth.png'
|
||
title="出生日期"
|
||
value={profile.birthDate ? (() => {
|
||
try {
|
||
const d = new Date(profile.birthDate);
|
||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
|
||
} catch {
|
||
return '1995年1月1日';
|
||
}
|
||
})() : '1995年1月1日'}
|
||
onPress={() => {
|
||
openDatePicker();
|
||
}}
|
||
/>
|
||
|
||
{/* 最大心率 */}
|
||
<ProfileCard
|
||
icon="heart"
|
||
iconColor="#FF6B9D"
|
||
title="最大心率"
|
||
value={profile.maxHeartRate ? `${Math.round(profile.maxHeartRate)}次/分钟` : '未获取'}
|
||
onPress={() => {
|
||
// 最大心率不可编辑,只显示
|
||
Alert.alert('提示', '最大心率数据从健康应用自动获取');
|
||
}}
|
||
disabled={true}
|
||
hideArrow={true}
|
||
/>
|
||
</View>
|
||
|
||
{/* 编辑弹窗 */}
|
||
<EditModal
|
||
visible={!!editingField}
|
||
field={editingField}
|
||
value={tempValue}
|
||
profile={profile}
|
||
onClose={() => {
|
||
setEditingField(null);
|
||
setTempValue('');
|
||
}}
|
||
onSave={async (field, value) => {
|
||
// 先更新本地状态
|
||
let updatedProfile = { ...profile };
|
||
if (field === 'name') {
|
||
updatedProfile.name = value;
|
||
setProfile(p => ({ ...p, name: value }));
|
||
} else if (field === 'gender') {
|
||
updatedProfile.gender = value as 'male' | 'female';
|
||
setProfile(p => ({ ...p, gender: value as 'male' | 'female' }));
|
||
} else if (field === 'height') {
|
||
updatedProfile.height = parseFloat(value) || undefined;
|
||
setProfile(p => ({ ...p, height: parseFloat(value) || undefined }));
|
||
|
||
} else if (field === 'weight') {
|
||
updatedProfile.weight = parseFloat(value) || undefined;
|
||
setProfile(p => ({ ...p, weight: parseFloat(value) || undefined }));
|
||
|
||
} else if (field === 'activity') {
|
||
const activityLevel = parseInt(value) as number;
|
||
updatedProfile.activityLevel = activityLevel;
|
||
setProfile(p => ({ ...p, activityLevel: activityLevel }));
|
||
}
|
||
|
||
setEditingField(null);
|
||
setTempValue('');
|
||
|
||
// 使用更新后的数据保存
|
||
await handleSaveWithProfile(updatedProfile);
|
||
}}
|
||
colors={colors}
|
||
textColor={textColor}
|
||
placeholderColor={placeholderColor}
|
||
/>
|
||
|
||
{/* 出生日期选择器弹窗 */}
|
||
<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(1900, 0, 1)}
|
||
maximumDate={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]}>
|
||
<Text style={styles.modalBtnText}>取消</Text>
|
||
</Pressable>
|
||
<Pressable onPress={() => {
|
||
onConfirmDate(pickerDate);
|
||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||
</Pressable>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</Modal>
|
||
</ScrollView>
|
||
</KeyboardAvoidingView>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
function ProfileCard({ icon, iconUri, iconColor, title, value, onPress, disabled, hideArrow }: {
|
||
icon?: keyof typeof Ionicons.glyphMap;
|
||
iconUri?: string;
|
||
iconColor?: string;
|
||
title: string;
|
||
value: string;
|
||
onPress: () => void;
|
||
disabled?: boolean;
|
||
hideArrow?: boolean;
|
||
}) {
|
||
const Container = disabled ? View : TouchableOpacity;
|
||
|
||
return (
|
||
<Container onPress={disabled ? undefined : onPress} style={styles.profileCard} {...(disabled ? {} : { activeOpacity: 0.8 })}>
|
||
<View style={styles.profileCardLeft}>
|
||
<View style={[styles.iconContainer]}>
|
||
{iconUri ? <Image
|
||
source={{ uri: iconUri }}
|
||
style={{ width: 20, height: 20 }}
|
||
cachePolicy="memory-disk" /> : <Ionicons name={icon} size={20} color={iconColor} />}
|
||
</View>
|
||
<Text style={styles.profileCardTitle}>{title}</Text>
|
||
</View>
|
||
<View style={styles.profileCardRight}>
|
||
<Text style={styles.profileCardValue}>{value}</Text>
|
||
{!hideArrow && <Ionicons name="chevron-forward" size={16} color="#C7C7CC" />}
|
||
</View>
|
||
</Container>
|
||
);
|
||
}
|
||
|
||
function EditModal({ visible, field, value, profile, onClose, onSave, colors, textColor, placeholderColor }: {
|
||
visible: boolean;
|
||
field: string | null;
|
||
value: string;
|
||
profile: UserProfile;
|
||
onClose: () => void;
|
||
onSave: (field: string, value: string) => void;
|
||
colors: any;
|
||
textColor: string;
|
||
placeholderColor: string;
|
||
}) {
|
||
const [inputValue, setInputValue] = useState(value);
|
||
const [selectedGender, setSelectedGender] = useState(profile.gender || 'female');
|
||
const [selectedActivity, setSelectedActivity] = useState(profile.activityLevel || 1);
|
||
|
||
useEffect(() => {
|
||
setInputValue(value);
|
||
if (field === 'activity') {
|
||
setSelectedActivity(profile.activityLevel || 1);
|
||
}
|
||
}, [value, field, profile.activityLevel]);
|
||
|
||
const renderContent = () => {
|
||
switch (field) {
|
||
case 'name':
|
||
return (
|
||
<View>
|
||
<Text style={styles.modalTitle}>昵称</Text>
|
||
<TextInput
|
||
style={[styles.modalInput, { color: textColor, borderColor: '#E0E0E0' }]}
|
||
placeholder="输入昵称"
|
||
placeholderTextColor={placeholderColor}
|
||
value={inputValue}
|
||
onChangeText={setInputValue}
|
||
autoFocus
|
||
/>
|
||
</View>
|
||
);
|
||
case 'gender':
|
||
return (
|
||
<View>
|
||
<Text style={styles.modalTitle}>性别</Text>
|
||
<View style={styles.genderSelector}>
|
||
<TouchableOpacity
|
||
style={[styles.genderOption, selectedGender === 'female' && { backgroundColor: colors.primary + '20' }]}
|
||
onPress={() => setSelectedGender('female')}
|
||
>
|
||
<Text style={[styles.genderEmoji, selectedGender === 'female' && { color: colors.primary }]}>♀</Text>
|
||
<Text style={[styles.genderText, selectedGender === 'female' && { color: colors.primary }]}>女性</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={[styles.genderOption, selectedGender === 'male' && { backgroundColor: colors.primary + '20' }]}
|
||
onPress={() => setSelectedGender('male')}
|
||
>
|
||
<Text style={[styles.genderEmoji, selectedGender === 'male' && { color: colors.primary }]}>♂</Text>
|
||
<Text style={[styles.genderText, selectedGender === 'male' && { color: colors.primary }]}>男性</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
);
|
||
case 'height':
|
||
return (
|
||
<View>
|
||
<Text style={styles.modalTitle}>身高</Text>
|
||
<View style={styles.pickerContainer}>
|
||
<Picker
|
||
selectedValue={inputValue}
|
||
onValueChange={setInputValue}
|
||
style={styles.picker}
|
||
>
|
||
{Array.from({ length: 101 }, (_, i) => 120 + i).map(height => (
|
||
<Picker.Item key={height} label={`${height}厘米`} value={String(height)} />
|
||
))}
|
||
</Picker>
|
||
</View>
|
||
</View>
|
||
);
|
||
case 'weight':
|
||
return (
|
||
<View>
|
||
<Text style={styles.modalTitle}>体重</Text>
|
||
<TextInput
|
||
style={[styles.modalInput, { color: textColor, borderColor: '#E0E0E0' }]}
|
||
placeholder="输入体重"
|
||
placeholderTextColor={placeholderColor}
|
||
value={inputValue}
|
||
onChangeText={setInputValue}
|
||
keyboardType="numeric"
|
||
autoFocus
|
||
/>
|
||
<Text style={styles.unitText}>公斤 (kg)</Text>
|
||
</View>
|
||
);
|
||
case 'activity':
|
||
return (
|
||
<View>
|
||
<Text style={styles.modalTitle}>活动水平</Text>
|
||
<View style={styles.activitySelector}>
|
||
{[
|
||
{ key: 1, label: '久坐', desc: '很少运动' },
|
||
{ key: 2, label: '轻度活跃', desc: '每周1-3次运动' },
|
||
{ key: 3, label: '中度活跃', desc: '每周3-5次运动' },
|
||
{ key: 4, label: '非常活跃', desc: '每周6-7次运动' },
|
||
].map(item => (
|
||
<TouchableOpacity
|
||
key={item.key}
|
||
style={[styles.activityOption, selectedActivity === item.key && { backgroundColor: colors.primary + '20' }]}
|
||
onPress={() => setSelectedActivity(item.key)}
|
||
>
|
||
<View style={styles.activityContent}>
|
||
<Text style={[styles.activityLabel, selectedActivity === item.key && { color: colors.primary }]}>{item.label}</Text>
|
||
<Text style={styles.activityDesc}>{item.desc}</Text>
|
||
</View>
|
||
{selectedActivity === item.key && <Ionicons name="checkmark" size={20} color={colors.primary} />}
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
</View>
|
||
);
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||
<Pressable style={styles.modalBackdrop} onPress={onClose} />
|
||
<View style={styles.editModalSheet}>
|
||
<View style={styles.modalHandle} />
|
||
{renderContent()}
|
||
<View style={styles.modalButtons}>
|
||
<TouchableOpacity onPress={onClose} style={styles.modalCancelBtn}>
|
||
<Text style={styles.modalCancelText}>取消</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
onPress={() => {
|
||
if (field === 'gender') {
|
||
onSave(field, selectedGender);
|
||
} else if (field === 'activity') {
|
||
onSave(field, String(selectedActivity));
|
||
} else {
|
||
onSave(field!, inputValue);
|
||
}
|
||
}}
|
||
style={[styles.modalSaveBtn, { backgroundColor: colors.primary }]}
|
||
>
|
||
<Text style={[styles.modalSaveText, { color: colors.onPrimary }]}>保存</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
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)',
|
||
},
|
||
avatarLoadingOverlay: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||
borderRadius: 60,
|
||
},
|
||
cardContainer: {
|
||
backgroundColor: '#FFFFFF',
|
||
borderRadius: 16,
|
||
overflow: 'hidden',
|
||
marginBottom: 20,
|
||
},
|
||
profileCard: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingVertical: 16,
|
||
paddingHorizontal: 16,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: '#F0F0F0',
|
||
},
|
||
profileCardLeft: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
flex: 1,
|
||
},
|
||
iconContainer: {
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 16,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 12,
|
||
},
|
||
profileCardTitle: {
|
||
fontSize: 16,
|
||
color: '#333333',
|
||
fontWeight: '500',
|
||
},
|
||
profileCardRight: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
profileCardValue: {
|
||
fontSize: 16,
|
||
color: '#666666',
|
||
marginRight: 8,
|
||
},
|
||
modalBackdrop: {
|
||
...StyleSheet.absoluteFillObject,
|
||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||
},
|
||
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: '#7a5af8',
|
||
},
|
||
modalBtnText: {
|
||
color: '#334155',
|
||
fontWeight: '700',
|
||
},
|
||
modalBtnTextPrimary: {
|
||
color: '#0F172A',
|
||
fontWeight: '700',
|
||
},
|
||
editModalSheet: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
backgroundColor: '#FFFFFF',
|
||
borderTopLeftRadius: 20,
|
||
borderTopRightRadius: 20,
|
||
paddingHorizontal: 20,
|
||
paddingBottom: 40,
|
||
paddingTop: 20,
|
||
},
|
||
modalHandle: {
|
||
width: 36,
|
||
height: 4,
|
||
backgroundColor: '#E0E0E0',
|
||
borderRadius: 2,
|
||
alignSelf: 'center',
|
||
marginBottom: 20,
|
||
},
|
||
modalTitle: {
|
||
fontSize: 20,
|
||
fontWeight: '600',
|
||
color: '#333333',
|
||
marginBottom: 20,
|
||
textAlign: 'center',
|
||
},
|
||
modalInput: {
|
||
height: 50,
|
||
borderWidth: 1,
|
||
borderRadius: 12,
|
||
paddingHorizontal: 16,
|
||
fontSize: 16,
|
||
marginBottom: 20,
|
||
},
|
||
unitText: {
|
||
fontSize: 14,
|
||
color: '#666666',
|
||
textAlign: 'center',
|
||
marginBottom: 20,
|
||
},
|
||
genderSelector: {
|
||
flexDirection: 'row',
|
||
gap: 16,
|
||
marginBottom: 20,
|
||
},
|
||
genderOption: {
|
||
flex: 1,
|
||
height: 80,
|
||
borderRadius: 12,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: '#F8F8F8',
|
||
},
|
||
genderEmoji: {
|
||
fontSize: 24,
|
||
marginBottom: 4,
|
||
},
|
||
genderText: {
|
||
fontSize: 16,
|
||
fontWeight: '500',
|
||
},
|
||
pickerContainer: {
|
||
height: 200,
|
||
marginBottom: 20,
|
||
},
|
||
picker: {
|
||
height: 200,
|
||
},
|
||
activitySelector: {
|
||
marginBottom: 20,
|
||
},
|
||
activityOption: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingVertical: 16,
|
||
paddingHorizontal: 16,
|
||
borderRadius: 12,
|
||
marginBottom: 8,
|
||
backgroundColor: '#F8F8F8',
|
||
},
|
||
activityContent: {
|
||
flex: 1,
|
||
},
|
||
activityLabel: {
|
||
fontSize: 16,
|
||
fontWeight: '500',
|
||
color: '#333333',
|
||
},
|
||
activityDesc: {
|
||
fontSize: 14,
|
||
color: '#666666',
|
||
marginTop: 2,
|
||
},
|
||
modalButtons: {
|
||
flexDirection: 'row',
|
||
gap: 12,
|
||
},
|
||
modalCancelBtn: {
|
||
flex: 1,
|
||
height: 50,
|
||
backgroundColor: '#F0F0F0',
|
||
borderRadius: 12,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
modalCancelText: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
color: '#666666',
|
||
},
|
||
modalSaveBtn: {
|
||
flex: 1,
|
||
height: 50,
|
||
borderRadius: 12,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
modalSaveText: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
},
|
||
});
|
||
|
||
|