feat: 更新用户资料编辑功能及相关组件
- 在 EditProfileScreen 中新增活动水平字段,支持用户设置和保存活动水平 - 更新个人信息卡片,增加活动水平的展示和编辑功能 - 在 ProfileCard 组件中优化样式,提升用户体验 - 更新 package.json 和 package-lock.json,新增 @react-native-picker/picker 依赖 - 在多个组件中引入 expo-image,优化图片加载和展示效果
This commit is contained in:
@@ -8,8 +8,9 @@ import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/sto
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Image, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg';
|
||||
@@ -110,8 +111,11 @@ export default function PersonalScreen() {
|
||||
<View style={styles.userInfoContainer}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<Image
|
||||
source={{ uri: userProfile.avatar || DEFAULT_AVATAR_URL }}
|
||||
source={userProfile.avatar || DEFAULT_AVATAR_URL}
|
||||
style={styles.avatar}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
cachePolicy="memory-disk"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.userDetails}>
|
||||
|
||||
@@ -8,14 +8,15 @@ import { fetchMyProfile } from '@/store/userSlice';
|
||||
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,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
@@ -30,8 +31,6 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
type WeightUnit = 'kg' | 'lb';
|
||||
type HeightUnit = 'cm' | 'ft';
|
||||
|
||||
interface UserProfile {
|
||||
name?: string;
|
||||
@@ -42,6 +41,7 @@ interface UserProfile {
|
||||
height?: number; // cm
|
||||
avatarUri?: string | null;
|
||||
avatarBase64?: string | null; // 兼容旧逻辑(不再上报)
|
||||
activityLevel?: number; // 活动水平 1-4
|
||||
}
|
||||
|
||||
const STORAGE_KEY = '@user_profile';
|
||||
@@ -68,6 +68,7 @@ export default function EditProfileScreen() {
|
||||
weight: undefined,
|
||||
height: undefined,
|
||||
avatarUri: null,
|
||||
activityLevel: undefined,
|
||||
});
|
||||
|
||||
const [weightInput, setWeightInput] = useState<string>('');
|
||||
@@ -76,6 +77,8 @@ export default function EditProfileScreen() {
|
||||
// 出生日期选择器
|
||||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||||
const [editingField, setEditingField] = useState<string | null>(null);
|
||||
const [tempValue, setTempValue] = useState<string>('');
|
||||
|
||||
// 输入框字符串
|
||||
|
||||
@@ -93,6 +96,7 @@ export default function EditProfileScreen() {
|
||||
weight: undefined,
|
||||
height: undefined,
|
||||
avatarUri: null,
|
||||
activityLevel: undefined,
|
||||
};
|
||||
if (fromOnboarding) {
|
||||
try {
|
||||
@@ -152,34 +156,20 @@ export default function EditProfileScreen() {
|
||||
: prev.avatarUri,
|
||||
weight: accountProfile?.weight ?? prev.weight ?? undefined,
|
||||
height: accountProfile?.height ?? prev.height ?? undefined,
|
||||
activityLevel: accountProfile?.activityLevel ?? prev.activityLevel ?? undefined,
|
||||
}));
|
||||
}, [accountProfile]);
|
||||
|
||||
const textColor = colors.text;
|
||||
const placeholderColor = colors.icon;
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSaveWithProfile = async (profileData: UserProfile) => {
|
||||
try {
|
||||
if (!userId) {
|
||||
Alert.alert('未登录', '请先登录后再尝试保存');
|
||||
return;
|
||||
}
|
||||
const next: UserProfile = { ...profile };
|
||||
|
||||
// 将当前输入同步为公制(固定 kg/cm)
|
||||
const w = parseFloat(weightInput);
|
||||
if (!isNaN(w)) {
|
||||
next.weight = w;
|
||||
} else {
|
||||
next.weight = undefined;
|
||||
}
|
||||
|
||||
const h = parseFloat(heightInput);
|
||||
if (!isNaN(h)) {
|
||||
next.height = h;
|
||||
} else {
|
||||
next.height = undefined;
|
||||
}
|
||||
const next: UserProfile = { ...profileData };
|
||||
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
|
||||
@@ -194,6 +184,7 @@ export default function EditProfileScreen() {
|
||||
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);
|
||||
@@ -201,16 +192,11 @@ export default function EditProfileScreen() {
|
||||
// 接口失败不阻断本地保存
|
||||
console.warn('更新用户信息失败', e?.message || e);
|
||||
}
|
||||
|
||||
Alert.alert('已保存', '个人资料已更新。');
|
||||
router.back();
|
||||
} catch (e) {
|
||||
Alert.alert('保存失败', '请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 不再需要单位切换
|
||||
|
||||
const { upload, uploading } = useCosUpload();
|
||||
|
||||
// 出生日期选择器交互
|
||||
@@ -221,14 +207,19 @@ export default function EditProfileScreen() {
|
||||
setDatePickerVisible(true);
|
||||
};
|
||||
const closeDatePicker = () => setDatePickerVisible(false);
|
||||
const onConfirmDate = (date: Date) => {
|
||||
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 () => {
|
||||
@@ -270,21 +261,21 @@ export default function EditProfileScreen() {
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
|
||||
<StatusBar barStyle={'dark-content'} />
|
||||
|
||||
|
||||
{/* HeaderBar 放在 ScrollView 外部,确保全宽显示 */}
|
||||
<HeaderBar
|
||||
title="编辑资料"
|
||||
onBack={() => router.back()}
|
||||
withSafeTop={false}
|
||||
<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: 16 }}>
|
||||
<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/avatar/avatarGirl01.jpeg' }} style={styles.avatarImage} />
|
||||
@@ -300,87 +291,132 @@ export default function EditProfileScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 姓名 */}
|
||||
<FieldLabel text="姓名" />
|
||||
<View style={[styles.inputWrapper, { borderColor: '#E0E0E0' }]}>
|
||||
<TextInput
|
||||
style={[styles.textInput, { color: textColor }]}
|
||||
placeholder="填写姓名(可选)"
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={profile.name}
|
||||
onChangeText={(t) => setProfile((p) => ({ ...p, name: t }))}
|
||||
{/* 用户信息卡片列表 */}
|
||||
<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();
|
||||
}}
|
||||
/>
|
||||
{/* 校验勾无需强制,仅装饰 */}
|
||||
{!!profile.name && <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>
|
||||
{/* 编辑弹窗 */}
|
||||
<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 }));
|
||||
setHeightInput(value);
|
||||
} else if (field === 'weight') {
|
||||
updatedProfile.weight = parseFloat(value) || undefined;
|
||||
setProfile(p => ({ ...p, weight: parseFloat(value) || undefined }));
|
||||
setWeightInput(value);
|
||||
} else if (field === 'activity') {
|
||||
const activityLevel = parseInt(value) as number;
|
||||
updatedProfile.activityLevel = activityLevel;
|
||||
setProfile(p => ({ ...p, activityLevel: activityLevel }));
|
||||
}
|
||||
|
||||
{/* 身高(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>
|
||||
setEditingField(null);
|
||||
setTempValue('');
|
||||
|
||||
{/* 性别 */}
|
||||
<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="出生日期" />
|
||||
<TouchableOpacity onPress={openDatePicker} activeOpacity={0.8} style={[styles.inputWrapper, { borderColor: '#E0E0E0' }]}>
|
||||
<Text style={[styles.textInput, { color: profile.birthDate ? textColor : placeholderColor }]}>
|
||||
{profile.birthDate
|
||||
? (() => {
|
||||
try {
|
||||
const d = new Date(profile.birthDate);
|
||||
return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }).format(d);
|
||||
} catch {
|
||||
return profile.birthDate;
|
||||
}
|
||||
})()
|
||||
: '选择出生日期(可选)'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
// 使用更新后的数据保存
|
||||
await handleSaveWithProfile(updatedProfile);
|
||||
}}
|
||||
colors={colors}
|
||||
textColor={textColor}
|
||||
placeholderColor={placeholderColor}
|
||||
/>
|
||||
|
||||
{/* 出生日期选择器弹窗 */}
|
||||
<Modal
|
||||
@@ -415,32 +451,201 @@ export default function EditProfileScreen() {
|
||||
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<Text style={styles.modalBtnText}>取消</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||
<Pressable onPress={() => {
|
||||
onConfirmDate(pickerDate);
|
||||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<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 }) {
|
||||
function ProfileCard({ icon, iconUri, iconColor, title, value, onPress }: {
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
iconUri?: string;
|
||||
iconColor?: string;
|
||||
title: string;
|
||||
value: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Text style={{ fontSize: 14, color: '#5E6468', marginBottom: 8, marginTop: 10 }}>{text}</Text>
|
||||
<TouchableOpacity onPress={onPress} style={styles.profileCard} 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>
|
||||
<Ionicons name="chevron-forward" size={16} color="#C7C7CC" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// 单位切换组件已移除(固定 kg/cm)
|
||||
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; }
|
||||
|
||||
@@ -481,71 +686,51 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
borderRadius: 60,
|
||||
},
|
||||
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,
|
||||
cardContainer: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
padding: 8,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 20,
|
||||
},
|
||||
selectorItem: {
|
||||
flex: 1,
|
||||
height: 48,
|
||||
borderRadius: 10,
|
||||
profileCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
},
|
||||
selectorEmoji: { fontSize: 16, marginBottom: 2 },
|
||||
selectorText: { fontSize: 15, fontWeight: '600' },
|
||||
saveBtn: {
|
||||
marginTop: 24,
|
||||
height: 56,
|
||||
profileCardLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
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.35)',
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
},
|
||||
modalSheet: {
|
||||
position: 'absolute',
|
||||
@@ -580,6 +765,129 @@ const styles = StyleSheet.create({
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TaskListItem } from '@/types/goals';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
@@ -35,7 +36,11 @@ export const TaskProgressCard: React.FC<TaskProgressCardProps> = ({
|
||||
style={styles.goalsIconButton}
|
||||
onPress={handleNavigateToGoals}
|
||||
>
|
||||
<MaterialIcons name="flag" size={18} color="#7A5AF8" />
|
||||
<Image
|
||||
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-goal-edit.png' }}
|
||||
style={{ width: 18, height: 18 }}
|
||||
cachePolicy="memory-disk"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
@@ -117,6 +122,8 @@ const styles = StyleSheet.create({
|
||||
gap: 8,
|
||||
},
|
||||
goalsIconButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
|
||||
@@ -1740,6 +1740,8 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- RNCPicker (2.11.1):
|
||||
- React-Core
|
||||
- RNDateTimePicker (8.4.4):
|
||||
- React-Core
|
||||
- RNDeviceInfo (14.0.4):
|
||||
@@ -2058,6 +2060,7 @@ DEPENDENCIES:
|
||||
- RNAppleHealthKit (from `../node_modules/react-native-health`)
|
||||
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
||||
- "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)"
|
||||
- "RNCPicker (from `../node_modules/@react-native-picker/picker`)"
|
||||
- "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)"
|
||||
- RNDeviceInfo (from `../node_modules/react-native-device-info`)
|
||||
- RNExitApp (from `../node_modules/react-native-exit-app`)
|
||||
@@ -2284,6 +2287,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/@react-native-async-storage/async-storage"
|
||||
RNCMaskedView:
|
||||
:path: "../node_modules/@react-native-masked-view/masked-view"
|
||||
RNCPicker:
|
||||
:path: "../node_modules/@react-native-picker/picker"
|
||||
RNDateTimePicker:
|
||||
:path: "../node_modules/@react-native-community/datetimepicker"
|
||||
RNDeviceInfo:
|
||||
@@ -2414,6 +2419,7 @@ SPEC CHECKSUMS:
|
||||
RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9
|
||||
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
|
||||
RNCMaskedView: d4644e239e65383f96d2f32c40c297f09705ac96
|
||||
RNCPicker: da0f1c9411208c1ca52bc98383db54a06e0a3862
|
||||
RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c
|
||||
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
|
||||
RNExitApp: 4432b9b7cc5ccec9f91c94e507849891282befd4
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-community/datetimepicker": "^8.4.4",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native-picker/picker": "^2.11.1",
|
||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||
"@react-navigation/elements": "^2.3.8",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
@@ -2920,6 +2921,19 @@
|
||||
"react-native": ">=0.57"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-picker/picker": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@react-native-picker/picker/-/picker-2.11.1.tgz",
|
||||
"integrity": "sha512-ThklnkK4fV3yynnIIRBkxxjxR4IFbdMNJVF6tlLdOJ/zEFUEFUEdXY0KmH0iYzMwY8W4/InWsLiA7AkpAbnexA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"example"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/assets-registry": {
|
||||
"version": "0.79.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.5.tgz",
|
||||
@@ -7115,7 +7129,7 @@
|
||||
},
|
||||
"node_modules/expo-image": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-2.4.0.tgz",
|
||||
"resolved": "https://mirrors.tencent.com/npm/expo-image/-/expo-image-2.4.0.tgz",
|
||||
"integrity": "sha512-TQ/LvrtJ9JBr+Tf198CAqflxcvdhuj7P24n0LQ1jHaWIVA7Z+zYKbYHnSMPSDMul/y0U46Z5bFLbiZiSidgcNw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-community/datetimepicker": "^8.4.4",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native-picker/picker": "^2.11.1",
|
||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||
"@react-navigation/elements": "^2.3.8",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
|
||||
@@ -13,6 +13,7 @@ export type UpdateUserDto = {
|
||||
pilatesPurposes?: string[];
|
||||
weight?: number;
|
||||
height?: number;
|
||||
activityLevel?: number; // 活动水平 1-4
|
||||
};
|
||||
|
||||
export async function updateUser(dto: UpdateUserDto): Promise<Record<string, any>> {
|
||||
|
||||
Reference in New Issue
Block a user