import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import * as ImagePicker from 'expo-image-picker'; import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, Alert, Image, KeyboardAvoidingView, Modal, Platform, Pressable, StyleSheet, TextInput, TouchableOpacity, View, type ImageSourcePropType, } from 'react-native'; import { ThemedText } from '@/components/themed-text'; import { deriveInitials } from '@/utils/profile-data'; import { uploadFile } from '@/lib/api/upload'; import { patchApiProfileName, patchApiProfileAvatar } from '@repo/loomart-sdk'; import { loomartClient } from '@/lib/api/loomart-client'; import { useAuth } from '@/hooks/use-auth'; type ProfileEditModalProps = { visible: boolean; avatarSource?: ImageSourcePropType; initialName: string; onClose: () => void; onSave: (payload: { name: string; avatar?: ImageSourcePropType }) => void; }; export function ProfileEditModal({ visible, avatarSource, initialName, onClose, onSave, }: ProfileEditModalProps) { const palette = palettes.dark; const { refetch } = useAuth(); const [nameValue, setNameValue] = useState(initialName); const [avatarCandidate, setAvatarCandidate] = useState(avatarSource); const [isUploading, setIsUploading] = useState(false); const [isSaving, setIsSaving] = useState(false); useEffect(() => { if (!visible) { return; } setNameValue(initialName); setAvatarCandidate(avatarSource); }, [visible, initialName, avatarSource]); const trimmedName = nameValue.trim(); const initialTrimmedName = initialName.trim(); const hasNameChanged = trimmedName !== initialTrimmedName; const avatarKey = getSourceKey(avatarCandidate); const initialAvatarKey = getSourceKey(avatarSource); const hasAvatarChanged = avatarKey !== initialAvatarKey; const isSaveDisabled = trimmedName.length === 0 || (!hasNameChanged && !hasAvatarChanged); const handlePickAvatar = useCallback(async () => { try { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!permission.granted) { Alert.alert('需要权限', '请允许访问相册以选择头像'); return; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: true, aspect: [1, 1], quality: 0.85, }); if (result.canceled || !result.assets?.length) { return; } const asset = result.assets[0]; if (!asset.uri) { return; } setIsUploading(true); const uploadResponse = await uploadFile(asset.uri, 'image'); setIsUploading(false); if (uploadResponse.success && uploadResponse.data?.url) { setAvatarCandidate({ uri: uploadResponse.data.url }); } else { Alert.alert('上传失败', uploadResponse.message || '图片上传失败,请稍后重试'); } } catch (error) { setIsUploading(false); console.error('Failed to pick avatar', error); Alert.alert('选择失败', '无法打开相册,请稍后重试'); } }, []); const handleSave = useCallback(async () => { if (trimmedName.length === 0) { return; } setIsSaving(true); try { if (hasNameChanged) { const nameResponse = await patchApiProfileName({ client: loomartClient, body: { name: trimmedName }, }); if (!(nameResponse.data as any)?.success) { throw new Error('更新用户名失败'); } } if (hasAvatarChanged && avatarCandidate && (avatarCandidate as any).uri) { const avatarResponse = await patchApiProfileAvatar({ client: loomartClient, body: { image: (avatarCandidate as any).uri! }, }); if (!(avatarResponse.data as any)?.success) { throw new Error('更新头像失败'); } } await refetch(); onSave({ name: trimmedName, avatar: avatarCandidate, }); onClose(); } catch (error) { console.error('保存失败:', error); Alert.alert('保存失败', error instanceof Error ? error.message : '保存用户信息失败,请稍后重试'); } finally { setIsSaving(false); } }, [avatarCandidate, hasNameChanged, hasAvatarChanged, onClose, onSave, trimmedName, refetch]); return ( {avatarCandidate ? ( ) : ( {deriveInitials(trimmedName || initialName)} )} {isUploading ? ( ) : ( )} Edit Profile {isSaving ? ( ) : ( Save )} ); } const palettes = { dark: { surface: '#121216', frame: '#1D1E24', avatarBackdrop: '#1F2026', field: '#18181D', primary: '#F6F7FA', muted: '#8E9098', placeholder: '#5B5D66', accent: '#D1FE17', onAccent: '#050505', buttonGhost: '#101014', }, light: { surface: '#FFFFFF', frame: '#D8DCE7', avatarBackdrop: '#E2E5EF', field: '#F3F5FC', primary: '#0F1320', muted: '#5E6474', placeholder: '#8C92A3', accent: '#405CFF', onAccent: '#FFFFFF', buttonGhost: '#EBEEF6', }, }; const styles = StyleSheet.create({ overlay: { flex: 1, justifyContent: 'flex-end', }, backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(5,5,5,0.68)', }, avoider: { width: '100%', }, card: { borderTopLeftRadius: 28, borderTopRightRadius: 28, paddingHorizontal: 24, paddingTop: 32, paddingBottom: 28, borderWidth: 1, borderBottomWidth: 0, width: '100%', }, closeButton: { alignSelf: 'flex-end', width: 34, height: 34, borderRadius: 17, borderWidth: 1, alignItems: 'center', justifyContent: 'center', marginBottom: 8, }, avatarSection: { alignItems: 'center', marginBottom: 24, }, avatarShell: { width: 88, height: 88, borderRadius: 44, overflow: 'hidden', borderWidth: 1, alignItems: 'center', justifyContent: 'center', }, avatarImage: { width: '100%', height: '100%', }, avatarInitials: { fontSize: 28, fontWeight: '700', }, cameraButton: { position: 'absolute', right: 18, bottom: 6, width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', }, modalTitle: { fontSize: 18, fontWeight: '700', textAlign: 'center', marginBottom: 20, }, fieldWrapper: { borderRadius: 14, borderWidth: 1, marginBottom: 24, paddingHorizontal: 16, paddingVertical: 12, }, textInput: { fontSize: 16, fontWeight: '600', }, saveButton: { borderRadius: 999, paddingVertical: 14, alignItems: 'center', justifyContent: 'center', }, saveLabel: { fontSize: 16, fontWeight: '700', }, }); function getSourceKey(source?: ImageSourcePropType): string | null { if (!source) { return null; } if (typeof source === 'number') { return String(source); } if (Array.isArray(source)) { return source.map(getSourceKey).join('|'); } if ('uri' in source && source.uri) { return source.uri; } return null; }