392 lines
11 KiB
TypeScript
392 lines
11 KiB
TypeScript
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<ImageSourcePropType | undefined>(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 (
|
|
<Modal
|
|
transparent
|
|
animationType="slide"
|
|
visible={visible}
|
|
onRequestClose={onClose}
|
|
>
|
|
<View style={styles.overlay}>
|
|
<Pressable style={styles.backdrop} onPress={onClose} accessibilityRole="button" />
|
|
<KeyboardAvoidingView
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
style={styles.avoider}
|
|
>
|
|
<View style={[styles.card, { backgroundColor: palette.surface, borderColor: palette.frame }]}>
|
|
<TouchableOpacity
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Close profile editor"
|
|
onPress={onClose}
|
|
style={[styles.closeButton, { backgroundColor: palette.buttonGhost, borderColor: palette.frame }]}
|
|
activeOpacity={0.85}
|
|
>
|
|
<MaterialIcons name="close" size={18} color={palette.muted} />
|
|
</TouchableOpacity>
|
|
|
|
<View style={styles.avatarSection}>
|
|
<View
|
|
style={[
|
|
styles.avatarShell,
|
|
{ backgroundColor: palette.avatarBackdrop, borderColor: palette.frame },
|
|
]}
|
|
>
|
|
{avatarCandidate ? (
|
|
<Image source={avatarCandidate} style={styles.avatarImage} resizeMode="cover" />
|
|
) : (
|
|
<ThemedText style={[styles.avatarInitials, { color: palette.primary }]}>
|
|
{deriveInitials(trimmedName || initialName)}
|
|
</ThemedText>
|
|
)}
|
|
</View>
|
|
<TouchableOpacity
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Choose a new profile picture"
|
|
onPress={handlePickAvatar}
|
|
disabled={isUploading}
|
|
style={[styles.cameraButton, { backgroundColor: palette.accent }]}
|
|
activeOpacity={0.85}
|
|
>
|
|
{isUploading ? (
|
|
<ActivityIndicator size="small" color={palette.onAccent} />
|
|
) : (
|
|
<MaterialIcons name="photo-camera" size={18} color={palette.onAccent} />
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<ThemedText style={[styles.modalTitle, { color: palette.primary }]}>
|
|
Edit Profile
|
|
</ThemedText>
|
|
|
|
<View style={[styles.fieldWrapper, { backgroundColor: palette.field, borderColor: palette.frame }]}>
|
|
<TextInput
|
|
value={nameValue}
|
|
onChangeText={setNameValue}
|
|
placeholder="Display name"
|
|
placeholderTextColor={palette.placeholder}
|
|
style={[styles.textInput, { color: palette.primary }]}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
returnKeyType="done"
|
|
onSubmitEditing={handleSave}
|
|
/>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Save profile changes"
|
|
onPress={handleSave}
|
|
disabled={isSaveDisabled || isSaving}
|
|
style={[
|
|
styles.saveButton,
|
|
{
|
|
backgroundColor: (isSaveDisabled || isSaving) ? palette.buttonGhost : palette.accent,
|
|
borderColor: (isSaveDisabled || isSaving) ? palette.frame : 'transparent',
|
|
borderWidth: (isSaveDisabled || isSaving) ? StyleSheet.hairlineWidth : 0,
|
|
},
|
|
]}
|
|
activeOpacity={0.9}
|
|
>
|
|
{isSaving ? (
|
|
<ActivityIndicator size="small" color={palette.onAccent} />
|
|
) : (
|
|
<ThemedText
|
|
style={[
|
|
styles.saveLabel,
|
|
{ color: isSaveDisabled ? palette.muted : palette.onAccent },
|
|
]}
|
|
>
|
|
Save
|
|
</ThemedText>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|