feat: 更新用户资料编辑功能及相关组件

- 在 EditProfileScreen 中新增活动水平字段,支持用户设置和保存活动水平
- 更新个人信息卡片,增加活动水平的展示和编辑功能
- 在 ProfileCard 组件中优化样式,提升用户体验
- 更新 package.json 和 package-lock.json,新增 @react-native-picker/picker 依赖
- 在多个组件中引入 expo-image,优化图片加载和展示效果
This commit is contained in:
richarjiang
2025-08-27 09:59:44 +08:00
parent 5e3203f1ce
commit a6dbe7c723
7 changed files with 518 additions and 177 deletions

View File

@@ -8,8 +8,9 @@ import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/sto
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { Image } from 'expo-image';
import React, { useEffect, useMemo, useState } from 'react'; 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'; 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'; 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.userInfoContainer}>
<View style={styles.avatarContainer}> <View style={styles.avatarContainer}>
<Image <Image
source={{ uri: userProfile.avatar || DEFAULT_AVATAR_URL }} source={userProfile.avatar || DEFAULT_AVATAR_URL}
style={styles.avatar} style={styles.avatar}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/> />
</View> </View>
<View style={styles.userDetails}> <View style={styles.userDetails}>

View File

@@ -8,14 +8,15 @@ import { fetchMyProfile } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import DateTimePicker from '@react-native-community/datetimepicker'; import DateTimePicker from '@react-native-community/datetimepicker';
import { Picker } from '@react-native-picker/picker';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Image,
KeyboardAvoidingView, KeyboardAvoidingView,
Modal, Modal,
Platform, Platform,
@@ -30,8 +31,6 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
type WeightUnit = 'kg' | 'lb';
type HeightUnit = 'cm' | 'ft';
interface UserProfile { interface UserProfile {
name?: string; name?: string;
@@ -42,6 +41,7 @@ interface UserProfile {
height?: number; // cm height?: number; // cm
avatarUri?: string | null; avatarUri?: string | null;
avatarBase64?: string | null; // 兼容旧逻辑(不再上报) avatarBase64?: string | null; // 兼容旧逻辑(不再上报)
activityLevel?: number; // 活动水平 1-4
} }
const STORAGE_KEY = '@user_profile'; const STORAGE_KEY = '@user_profile';
@@ -68,6 +68,7 @@ export default function EditProfileScreen() {
weight: undefined, weight: undefined,
height: undefined, height: undefined,
avatarUri: null, avatarUri: null,
activityLevel: undefined,
}); });
const [weightInput, setWeightInput] = useState<string>(''); const [weightInput, setWeightInput] = useState<string>('');
@@ -76,6 +77,8 @@ export default function EditProfileScreen() {
// 出生日期选择器 // 出生日期选择器
const [datePickerVisible, setDatePickerVisible] = useState(false); const [datePickerVisible, setDatePickerVisible] = useState(false);
const [pickerDate, setPickerDate] = useState<Date>(new Date()); 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, weight: undefined,
height: undefined, height: undefined,
avatarUri: null, avatarUri: null,
activityLevel: undefined,
}; };
if (fromOnboarding) { if (fromOnboarding) {
try { try {
@@ -152,34 +156,20 @@ export default function EditProfileScreen() {
: prev.avatarUri, : prev.avatarUri,
weight: accountProfile?.weight ?? prev.weight ?? undefined, weight: accountProfile?.weight ?? prev.weight ?? undefined,
height: accountProfile?.height ?? prev.height ?? undefined, height: accountProfile?.height ?? prev.height ?? undefined,
activityLevel: accountProfile?.activityLevel ?? prev.activityLevel ?? undefined,
})); }));
}, [accountProfile]); }, [accountProfile]);
const textColor = colors.text; const textColor = colors.text;
const placeholderColor = colors.icon; const placeholderColor = colors.icon;
const handleSave = async () => { const handleSaveWithProfile = async (profileData: UserProfile) => {
try { try {
if (!userId) { if (!userId) {
Alert.alert('未登录', '请先登录后再尝试保存'); Alert.alert('未登录', '请先登录后再尝试保存');
return; return;
} }
const next: UserProfile = { ...profile }; const next: UserProfile = { ...profileData };
// 将当前输入同步为公制(固定 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;
}
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)); await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
@@ -194,6 +184,7 @@ export default function EditProfileScreen() {
weight: next.weight || undefined, weight: next.weight || undefined,
height: next.height || undefined, height: next.height || undefined,
birthDate: next.birthDate ? new Date(next.birthDate).getTime() / 1000 : undefined, birthDate: next.birthDate ? new Date(next.birthDate).getTime() / 1000 : undefined,
activityLevel: next.activityLevel || undefined,
}); });
// 拉取最新用户信息,刷新全局状态 // 拉取最新用户信息,刷新全局状态
await dispatch(fetchMyProfile() as any); await dispatch(fetchMyProfile() as any);
@@ -201,16 +192,11 @@ export default function EditProfileScreen() {
// 接口失败不阻断本地保存 // 接口失败不阻断本地保存
console.warn('更新用户信息失败', e?.message || e); console.warn('更新用户信息失败', e?.message || e);
} }
Alert.alert('已保存', '个人资料已更新。');
router.back();
} catch (e) { } catch (e) {
Alert.alert('保存失败', '请稍后重试'); Alert.alert('保存失败', '请稍后重试');
} }
}; };
// 不再需要单位切换
const { upload, uploading } = useCosUpload(); const { upload, uploading } = useCosUpload();
// 出生日期选择器交互 // 出生日期选择器交互
@@ -221,14 +207,19 @@ export default function EditProfileScreen() {
setDatePickerVisible(true); setDatePickerVisible(true);
}; };
const closeDatePicker = () => setDatePickerVisible(false); const closeDatePicker = () => setDatePickerVisible(false);
const onConfirmDate = (date: Date) => { const onConfirmDate = async (date: Date) => {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const picked = new Date(date); const picked = new Date(date);
picked.setHours(0, 0, 0, 0); picked.setHours(0, 0, 0, 0);
const finalDate = picked > today ? today : picked; const finalDate = picked > today ? today : picked;
const updatedProfile = { ...profile, birthDate: finalDate.toISOString() };
setProfile((p) => ({ ...p, birthDate: finalDate.toISOString() })); setProfile((p) => ({ ...p, birthDate: finalDate.toISOString() }));
closeDatePicker(); closeDatePicker();
// 保存到后端
await handleSaveWithProfile(updatedProfile);
}; };
const pickAvatarFromLibrary = async () => { const pickAvatarFromLibrary = async () => {
@@ -284,7 +275,7 @@ export default function EditProfileScreen() {
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}> <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}> <TouchableOpacity activeOpacity={0.85} onPress={pickAvatarFromLibrary} disabled={uploading}>
<View style={styles.avatarCircle}> <View style={styles.avatarCircle}>
<Image source={{ uri: profile.avatarUri || 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg' }} style={styles.avatarImage} /> <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> </TouchableOpacity>
</View> </View>
{/* 姓名 */} {/* 用户信息卡片列表 */}
<FieldLabel text="姓名" /> <View style={styles.cardContainer}>
<View style={[styles.inputWrapper, { borderColor: '#E0E0E0' }]}> {/* 姓名 */}
<TextInput <ProfileCard
style={[styles.textInput, { color: textColor }]} iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-name.png'
placeholder="填写姓名(可选)" title="昵称"
placeholderTextColor={placeholderColor} value={profile.name || '今晚要吃肉'}
value={profile.name} onPress={() => {
onChangeText={(t) => setProfile((p) => ({ ...p, name: t }))} 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> </View>
{/* 体重kg */} {/* 编辑弹窗 */}
<FieldLabel text="体重" /> <EditModal
<View style={styles.row}> visible={!!editingField}
<View style={[styles.inputWrapper, styles.flex1, { borderColor: '#E0E0E0' }]}> field={editingField}
<TextInput value={tempValue}
style={[styles.textInput, { color: textColor }]} profile={profile}
placeholder={'输入体重'} onClose={() => {
placeholderTextColor={placeholderColor} setEditingField(null);
keyboardType="numeric" setTempValue('');
value={weightInput} }}
onChangeText={setWeightInput} onSave={async (field, value) => {
/> // 先更新本地状态
<Text style={{ color: '#5E6468', marginLeft: 6 }}>kg</Text> let updatedProfile = { ...profile };
</View> if (field === 'name') {
</View> 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 */} setEditingField(null);
<FieldLabel text="身高" /> setTempValue('');
<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="性别" /> await handleSaveWithProfile(updatedProfile);
<View style={[styles.selector, { borderColor: '#E0E0E0' }]}> }}
<TouchableOpacity colors={colors}
style={[styles.selectorItem, profile.gender === 'female' && { backgroundColor: colors.primary + '40' }]} textColor={textColor}
onPress={() => setProfile((p) => ({ ...p, gender: 'female' }))} placeholderColor={placeholderColor}
> />
<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>
{/* 出生日期选择器弹窗 */} {/* 出生日期选择器弹窗 */}
<Modal <Modal
@@ -415,32 +451,201 @@ export default function EditProfileScreen() {
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}> <Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text> <Text style={styles.modalBtnText}></Text>
</Pressable> </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> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
</Pressable> </Pressable>
</View> </View>
)} )}
</View> </View>
</Modal> </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> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </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 ( 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; } 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)', backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 60, borderRadius: 60,
}, },
inputWrapper: { cardContainer: {
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', backgroundColor: '#FFFFFF',
borderWidth: 1, borderRadius: 16,
borderRadius: 12, overflow: 'hidden',
padding: 8, marginBottom: 20,
}, },
selectorItem: { profileCard: {
flex: 1, flexDirection: 'row',
height: 48,
borderRadius: 10,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#F0F0F0',
}, },
selectorEmoji: { fontSize: 16, marginBottom: 2 }, profileCardLeft: {
selectorText: { fontSize: 15, fontWeight: '600' }, flexDirection: 'row',
saveBtn: { alignItems: 'center',
marginTop: 24, flex: 1,
height: 56, },
iconContainer: {
width: 32,
height: 32,
borderRadius: 16, borderRadius: 16,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
shadowColor: '#000', marginRight: 12,
shadowOffset: { width: 0, height: 2 }, },
shadowOpacity: 0.1, profileCardTitle: {
shadowRadius: 4, fontSize: 16,
elevation: 4, color: '#333333',
fontWeight: '500',
},
profileCardRight: {
flexDirection: 'row',
alignItems: 'center',
},
profileCardValue: {
fontSize: 16,
color: '#666666',
marginRight: 8,
}, },
modalBackdrop: { modalBackdrop: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.35)', backgroundColor: 'rgba(0,0,0,0.4)',
}, },
modalSheet: { modalSheet: {
position: 'absolute', position: 'absolute',
@@ -580,6 +765,129 @@ const styles = StyleSheet.create({
color: '#0F172A', color: '#0F172A',
fontWeight: '700', 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',
},
}); });

View File

@@ -1,5 +1,6 @@
import { TaskListItem } from '@/types/goals'; import { TaskListItem } from '@/types/goals';
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
@@ -35,7 +36,11 @@ export const TaskProgressCard: React.FC<TaskProgressCardProps> = ({
style={styles.goalsIconButton} style={styles.goalsIconButton}
onPress={handleNavigateToGoals} 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> </TouchableOpacity>
</View> </View>
<View style={styles.headerActions}> <View style={styles.headerActions}>
@@ -117,6 +122,8 @@ const styles = StyleSheet.create({
gap: 8, gap: 8,
}, },
goalsIconButton: { goalsIconButton: {
width: 24,
height: 24,
}, },
title: { title: {
fontSize: 20, fontSize: 20,

View File

@@ -1740,6 +1740,8 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Yoga - Yoga
- RNCPicker (2.11.1):
- React-Core
- RNDateTimePicker (8.4.4): - RNDateTimePicker (8.4.4):
- React-Core - React-Core
- RNDeviceInfo (14.0.4): - RNDeviceInfo (14.0.4):
@@ -2058,6 +2060,7 @@ DEPENDENCIES:
- RNAppleHealthKit (from `../node_modules/react-native-health`) - RNAppleHealthKit (from `../node_modules/react-native-health`)
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)" - "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`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)"
- RNDeviceInfo (from `../node_modules/react-native-device-info`) - RNDeviceInfo (from `../node_modules/react-native-device-info`)
- RNExitApp (from `../node_modules/react-native-exit-app`) - RNExitApp (from `../node_modules/react-native-exit-app`)
@@ -2284,6 +2287,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-async-storage/async-storage" :path: "../node_modules/@react-native-async-storage/async-storage"
RNCMaskedView: RNCMaskedView:
:path: "../node_modules/@react-native-masked-view/masked-view" :path: "../node_modules/@react-native-masked-view/masked-view"
RNCPicker:
:path: "../node_modules/@react-native-picker/picker"
RNDateTimePicker: RNDateTimePicker:
:path: "../node_modules/@react-native-community/datetimepicker" :path: "../node_modules/@react-native-community/datetimepicker"
RNDeviceInfo: RNDeviceInfo:
@@ -2414,6 +2419,7 @@ SPEC CHECKSUMS:
RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9 RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9
RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f
RNCMaskedView: d4644e239e65383f96d2f32c40c297f09705ac96 RNCMaskedView: d4644e239e65383f96d2f32c40c297f09705ac96
RNCPicker: da0f1c9411208c1ca52bc98383db54a06e0a3862
RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047 RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
RNExitApp: 4432b9b7cc5ccec9f91c94e507849891282befd4 RNExitApp: 4432b9b7cc5ccec9f91c94e507849891282befd4

16
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@react-native-async-storage/async-storage": "^2.2.0", "@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/datetimepicker": "^8.4.4", "@react-native-community/datetimepicker": "^8.4.4",
"@react-native-masked-view/masked-view": "^0.3.2", "@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/bottom-tabs": "^7.3.10",
"@react-navigation/elements": "^2.3.8", "@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6", "@react-navigation/native": "^7.1.6",
@@ -2920,6 +2921,19 @@
"react-native": ">=0.57" "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": { "node_modules/@react-native/assets-registry": {
"version": "0.79.5", "version": "0.79.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.5.tgz", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.5.tgz",
@@ -7115,7 +7129,7 @@
}, },
"node_modules/expo-image": { "node_modules/expo-image": {
"version": "2.4.0", "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==", "integrity": "sha512-TQ/LvrtJ9JBr+Tf198CAqflxcvdhuj7P24n0LQ1jHaWIVA7Z+zYKbYHnSMPSDMul/y0U46Z5bFLbiZiSidgcNw==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {

View File

@@ -16,6 +16,7 @@
"@react-native-async-storage/async-storage": "^2.2.0", "@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/datetimepicker": "^8.4.4", "@react-native-community/datetimepicker": "^8.4.4",
"@react-native-masked-view/masked-view": "^0.3.2", "@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/bottom-tabs": "^7.3.10",
"@react-navigation/elements": "^2.3.8", "@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6", "@react-navigation/native": "^7.1.6",

View File

@@ -13,6 +13,7 @@ export type UpdateUserDto = {
pilatesPurposes?: string[]; pilatesPurposes?: string[];
weight?: number; weight?: number;
height?: number; height?: number;
activityLevel?: number; // 活动水平 1-4
}; };
export async function updateUser(dto: UpdateUserDto): Promise<Record<string, any>> { export async function updateUser(dto: UpdateUserDto): Promise<Record<string, any>> {