bw-expo-app/components/profile/profile-edit-modal.tsx

334 lines
9.0 KiB
TypeScript

import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import * as ImagePicker from 'expo-image-picker';
import React, { useCallback, useEffect, useState } from 'react';
import {
Alert,
Image,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
StyleSheet,
TextInput,
TouchableOpacity,
View,
type ImageSourcePropType,
} from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { deriveInitials } from '@/utils/profile-data';
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 colorScheme = useColorScheme();
const palette = colorScheme === 'dark' ? palettes.dark : palettes.light;
const [nameValue, setNameValue] = useState(initialName);
const [avatarCandidate, setAvatarCandidate] = useState<ImageSourcePropType | undefined>(avatarSource);
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('Permission Required', 'Allow photo library access to choose a new portrait.');
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;
}
setAvatarCandidate({ uri: asset.uri });
} catch (error) {
console.error('Failed to pick avatar', error);
Alert.alert('Selection Failed', 'We could not open your photo library. Please try again.');
}
}, []);
const handleSave = useCallback(() => {
if (trimmedName.length === 0) {
return;
}
onSave({
name: trimmedName,
avatar: avatarCandidate,
});
}, [avatarCandidate, onSave, trimmedName]);
return (
<Modal
transparent
animationType="fade"
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}
style={[styles.cameraButton, { backgroundColor: palette.accent }]}
activeOpacity={0.85}
>
<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}
style={[
styles.saveButton,
{
backgroundColor: isSaveDisabled ? palette.buttonGhost : palette.accent,
borderColor: isSaveDisabled ? palette.frame : 'transparent',
borderWidth: isSaveDisabled ? StyleSheet.hairlineWidth : 0,
},
]}
activeOpacity={0.9}
>
<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: '#B7FF2F',
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: 'center',
alignItems: 'center',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(5,5,5,0.68)',
},
avoider: {
width: '100%',
paddingHorizontal: 28,
},
card: {
borderRadius: 24,
paddingHorizontal: 24,
paddingTop: 32,
paddingBottom: 28,
borderWidth: 1,
alignSelf: 'center',
width: '100%',
maxWidth: 360,
},
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;
}