- 将药品AI分析从Markdown流式输出重构为结构化数据展示(V2) - 新增适合人群、不适合人群、主要成分、副作用等分类卡片展示 - 优化AI分析UI布局,采用卡片式设计提升可读性 - 新增药品跳过功能,支持用户标记本次用药为已跳过 - 修复喝水提醒逻辑,支持用户开关控制和自定义时间段配置 - 优化个人资料编辑页面键盘适配,避免输入框被遮挡 - 统一API响应码处理,兼容200和0两种成功状态码 - 更新版本号至1.0.28 BREAKING CHANGE: 药品AI分析接口从流式Markdown输出改为结构化JSON格式,旧版本分析结果将不再显示
996 lines
34 KiB
TypeScript
996 lines
34 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 { useI18n } from '@/hooks/useI18n';
|
||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||
import { syncServerToHealthKit } from '@/services/healthKitSync';
|
||
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
|
||
import { fetchMaximumHeartRate } from '@/utils/health';
|
||
import AsyncStorage from '@/utils/kvStore';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
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,
|
||
Keyboard,
|
||
KeyboardAvoidingView,
|
||
Modal,
|
||
Platform,
|
||
Pressable,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Text,
|
||
TextInput,
|
||
TouchableOpacity,
|
||
View
|
||
} from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
|
||
interface UserProfile {
|
||
name?: string;
|
||
gender?: 'male' | 'female' | '';
|
||
birthDate?: string; // 出生日期
|
||
// 以公制为基准存储
|
||
weight?: number; // kg
|
||
height?: number; // cm
|
||
avatarUri?: string | null;
|
||
activityLevel?: number; // 活动水平 1-4
|
||
maxHeartRate?: number; // 最大心率
|
||
}
|
||
|
||
const STORAGE_KEY = '@user_profile';
|
||
|
||
export default function EditProfileScreen() {
|
||
const { t } = useI18n();
|
||
const safeAreaTop = useSafeAreaTop()
|
||
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 [keyboardHeight, setKeyboardHeight] = useState(0);
|
||
|
||
// 从本地存储加载(身高/体重等本地字段)
|
||
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(() => {
|
||
// 只有在编辑名称或体重字段时才需要监听键盘(这两个字段使用 TextInput)
|
||
const needsKeyboardHandling = editingField === 'name' || editingField === 'weight';
|
||
|
||
if (!needsKeyboardHandling) {
|
||
setKeyboardHeight(0);
|
||
return;
|
||
}
|
||
|
||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||
|
||
const handleShow = (event: any) => {
|
||
const height = event?.endCoordinates?.height ?? 0;
|
||
setKeyboardHeight(height);
|
||
};
|
||
const handleHide = () => setKeyboardHeight(0);
|
||
|
||
const showSub = Keyboard.addListener(showEvent, handleShow);
|
||
const hideSub = Keyboard.addListener(hideEvent, handleHide);
|
||
|
||
return () => {
|
||
showSub.remove();
|
||
hideSub.remove();
|
||
};
|
||
}, [editingField]);
|
||
|
||
// 获取最大心率数据
|
||
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(t('editProfile.alerts.notLoggedIn.title'), t('editProfile.alerts.notLoggedIn.message'));
|
||
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);
|
||
|
||
// 同步身高、体重到 HealthKit
|
||
console.log('开始同步个人健康数据到 HealthKit...');
|
||
const syncSuccess = await syncServerToHealthKit({
|
||
height: next.height,
|
||
weight: next.weight,
|
||
birthDate: next.birthDate ? new Date(next.birthDate).getTime() / 1000 : undefined
|
||
});
|
||
|
||
if (syncSuccess) {
|
||
console.log('个人健康数据已同步到 HealthKit');
|
||
}
|
||
} catch (e: any) {
|
||
// 接口失败不阻断本地保存
|
||
console.warn('更新用户信息失败', e?.message || e);
|
||
}
|
||
} catch (e) {
|
||
Alert.alert(t('editProfile.alerts.saveFailed.title'), t('editProfile.alerts.saveFailed.message'));
|
||
}
|
||
};
|
||
|
||
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(t('editProfile.alerts.avatarPermissions.title'), t('editProfile.alerts.avatarPermissions.message'));
|
||
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 }
|
||
);
|
||
|
||
console.log('url', url);
|
||
|
||
|
||
setProfile((p) => ({ ...p, avatarUri: url }));
|
||
// 保存更新后的 profile
|
||
await handleSaveWithProfile({ ...profile, avatarUri: url });
|
||
Alert.alert(t('editProfile.alerts.avatarSuccess.title'), t('editProfile.alerts.avatarSuccess.message'));
|
||
} catch (e) {
|
||
console.warn('上传头像失败', e);
|
||
Alert.alert(t('editProfile.alerts.avatarUploadFailed.title'), t('editProfile.alerts.avatarUploadFailed.message'));
|
||
}
|
||
}
|
||
} catch (e) {
|
||
Alert.alert(t('editProfile.alerts.avatarError.title'), t('editProfile.alerts.avatarError.message'));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<View style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
|
||
<HeaderBar
|
||
title={t('editProfile.title')}
|
||
onBack={() => router.back()}
|
||
withSafeTop={false}
|
||
transparent={true}
|
||
variant="elevated"
|
||
/>
|
||
|
||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
|
||
<ScrollView contentContainerStyle={{ paddingBottom: 40, paddingTop: safeAreaTop }} 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={t('editProfile.fields.name')}
|
||
value={profile.name || t('editProfile.defaultValues.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={t('editProfile.fields.gender')}
|
||
value={profile.gender === 'male' ? t('editProfile.gender.male') : profile.gender === 'female' ? t('editProfile.gender.female') : t('editProfile.gender.notSet')}
|
||
onPress={() => {
|
||
setEditingField('gender');
|
||
}}
|
||
/>
|
||
|
||
{/* 身高 */}
|
||
<ProfileCard
|
||
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-height.png"
|
||
title={t('editProfile.fields.height')}
|
||
value={profile.height ? `${Math.round(profile.height)}${t('editProfile.height.unit')}` : t('editProfile.height.placeholder')}
|
||
onPress={() => {
|
||
setTempValue(profile.height ? String(Math.round(profile.height)) : String(t('editProfile.defaultValues.height')));
|
||
setEditingField('height');
|
||
}}
|
||
/>
|
||
|
||
{/* 体重 */}
|
||
<ProfileCard
|
||
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-weight.png"
|
||
title={t('editProfile.fields.weight')}
|
||
value={profile.weight ? `${round(profile.weight, 1)}${t('editProfile.weight.unit')}` : t('editProfile.weight.placeholder')}
|
||
onPress={() => {
|
||
setTempValue(profile.weight ? String(round(profile.weight, 1)) : String(t('editProfile.defaultValues.weight')));
|
||
setEditingField('weight');
|
||
}}
|
||
/>
|
||
|
||
{/* 活动水平 */}
|
||
<ProfileCard
|
||
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-activity.png'
|
||
title={t('editProfile.fields.activityLevel')}
|
||
value={(() => {
|
||
switch (profile.activityLevel) {
|
||
case 1: return t('editProfile.activityLevels.1');
|
||
case 2: return t('editProfile.activityLevels.2');
|
||
case 3: return t('editProfile.activityLevels.3');
|
||
case 4: return t('editProfile.activityLevels.4');
|
||
default: return t('editProfile.activityLevels.1');
|
||
}
|
||
})()}
|
||
onPress={() => {
|
||
setEditingField('activity');
|
||
}}
|
||
/>
|
||
|
||
{/* 出生日期 */}
|
||
<ProfileCard
|
||
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-birth.png'
|
||
title={t('editProfile.fields.birthDate')}
|
||
value={profile.birthDate ? (() => {
|
||
try {
|
||
const d = new Date(profile.birthDate);
|
||
if (t('editProfile.birthDate.format').includes('{{year}}年')) {
|
||
return t('editProfile.birthDate.format', { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() });
|
||
} else {
|
||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||
return t('editProfile.birthDate.format', { year: d.getFullYear(), month: monthNames[d.getMonth()], day: d.getDate() });
|
||
}
|
||
} catch {
|
||
return t('editProfile.birthDate.placeholder');
|
||
}
|
||
})() : t('editProfile.birthDate.placeholder')}
|
||
onPress={() => {
|
||
openDatePicker();
|
||
}}
|
||
/>
|
||
|
||
{/* 最大心率 */}
|
||
<ProfileCard
|
||
icon="heart"
|
||
iconColor="#FF6B9D"
|
||
title={t('editProfile.fields.maxHeartRate')}
|
||
value={profile.maxHeartRate ? `${Math.round(profile.maxHeartRate)}${t('editProfile.maxHeartRate.unit')}` : t('editProfile.maxHeartRate.notAvailable')}
|
||
onPress={() => {
|
||
// 最大心率不可编辑,只显示
|
||
Alert.alert(t('editProfile.maxHeartRate.alert.title'), t('editProfile.maxHeartRate.alert.message'));
|
||
}}
|
||
disabled={true}
|
||
hideArrow={true}
|
||
/>
|
||
</View>
|
||
|
||
{/* 编辑弹窗 */}
|
||
<EditModal
|
||
visible={!!editingField}
|
||
field={editingField}
|
||
value={tempValue}
|
||
profile={profile}
|
||
keyboardHeight={keyboardHeight}
|
||
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}
|
||
t={t}
|
||
/>
|
||
{/* 出生日期选择器弹窗 */}
|
||
<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}>{t('editProfile.modals.cancel')}</Text>
|
||
</Pressable>
|
||
<Pressable onPress={() => {
|
||
onConfirmDate(pickerDate);
|
||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('editProfile.modals.confirm')}</Text>
|
||
</Pressable>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</Modal>
|
||
</ScrollView>
|
||
</KeyboardAvoidingView>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
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, keyboardHeight, onClose, onSave, colors, textColor, placeholderColor, t }: {
|
||
visible: boolean;
|
||
field: string | null;
|
||
value: string;
|
||
profile: UserProfile;
|
||
keyboardHeight: number;
|
||
onClose: () => void;
|
||
onSave: (field: string, value: string) => void;
|
||
colors: any;
|
||
textColor: string;
|
||
placeholderColor: string;
|
||
t: (key: string) => string;
|
||
}) {
|
||
const insets = useSafeAreaInsets();
|
||
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}>{t('editProfile.fields.name')}</Text>
|
||
<TextInput
|
||
style={[styles.modalInput, { color: textColor, borderColor: '#E0E0E0' }]}
|
||
placeholder={t('editProfile.modals.input.namePlaceholder')}
|
||
placeholderTextColor={placeholderColor}
|
||
value={inputValue}
|
||
onChangeText={setInputValue}
|
||
autoFocus
|
||
/>
|
||
</View>
|
||
);
|
||
case 'gender':
|
||
return (
|
||
<View>
|
||
<Text style={styles.modalTitle}>{t('editProfile.fields.gender')}</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 }]}>{t('editProfile.modals.female')}</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 }]}>{t('editProfile.modals.male')}</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
);
|
||
case 'height':
|
||
return (
|
||
<View>
|
||
<Text style={styles.modalTitle}>{t('editProfile.fields.height')}</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}${t('editProfile.height.unit')}`} value={String(height)} />
|
||
))}
|
||
</Picker>
|
||
</View>
|
||
</View>
|
||
);
|
||
case 'weight':
|
||
return (
|
||
<View>
|
||
<Text style={styles.modalTitle}>{t('editProfile.fields.weight')}</Text>
|
||
<TextInput
|
||
style={[styles.modalInput, { color: textColor, borderColor: '#E0E0E0' }]}
|
||
placeholder={t('editProfile.modals.input.weightPlaceholder')}
|
||
placeholderTextColor={placeholderColor}
|
||
value={inputValue}
|
||
onChangeText={setInputValue}
|
||
keyboardType="numeric"
|
||
autoFocus
|
||
/>
|
||
<Text style={styles.unitText}>{t('editProfile.modals.input.weightUnit')}</Text>
|
||
</View>
|
||
);
|
||
case 'activity':
|
||
return (
|
||
<View>
|
||
<Text style={styles.modalTitle}>{t('editProfile.fields.activityLevel')}</Text>
|
||
<View style={styles.activitySelector}>
|
||
{[
|
||
{ key: 1, label: t('editProfile.activityLevels.1'), desc: t('editProfile.activityLevels.descriptions.1') },
|
||
{ key: 2, label: t('editProfile.activityLevels.2'), desc: t('editProfile.activityLevels.descriptions.2') },
|
||
{ key: 3, label: t('editProfile.activityLevels.3'), desc: t('editProfile.activityLevels.descriptions.3') },
|
||
{ key: 4, label: t('editProfile.activityLevels.4'), desc: t('editProfile.activityLevels.descriptions.4') },
|
||
].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,
|
||
{ paddingBottom: Math.max(keyboardHeight, insets.bottom) + 12 }
|
||
]}>
|
||
<View style={styles.modalHandle} />
|
||
{renderContent()}
|
||
<View style={styles.modalButtons}>
|
||
<TouchableOpacity onPress={onClose} style={styles.modalCancelBtn}>
|
||
<Text style={styles.modalCancelText}>{t('editProfile.modals.cancel')}</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 }]}>{t('editProfile.modals.save')}</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',
|
||
},
|
||
});
|
||
|
||
|