refactor(profile): edit modal as keyboard-aware bottom sheet

Remplace le Modal+KeyboardAvoidingView+ScrollView par un BottomSheet
(@gorhom/bottom-sheet) avec snap 95% + topInset safe-area +
keyboardBehavior 'interactive' + android_keyboardInputMode 'adjustResize'.

L'API publique du composant (visible/onClose/onSave) est inchangée.

Boutons Cancel/Save dans le BottomSheetScrollView (juste après le dernier
input email) avec icônes X/Check, layout row via inner View, ghost grisé
bordé + primary shadow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yanis 2026-05-01 00:03:43 +02:00
parent 0d97be422e
commit 0ce7b3a718

View file

@ -1,21 +1,27 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
View,
Modal,
Pressable,
TextInput,
StyleSheet,
KeyboardAvoidingView,
Platform,
ScrollView,
Text as RNText,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import BottomSheet, {
BottomSheetScrollView,
BottomSheetTextInput,
} from '@gorhom/bottom-sheet';
import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react-native';
import { X, Check } from 'lucide-react-native';
import { toast } from 'sonner-native';
import { Text } from '@/components/ui/text';
import { colors } from '@/theme/colors';
import { AVATAR_OPTIONS, isValidEmail, type AvatarEmoji, type UserProfile } from '@/types/user';
import {
AVATAR_OPTIONS,
isValidEmail,
type AvatarEmoji,
type UserProfile,
} from '@/types/user';
interface EditProfileModalProps {
visible: boolean;
@ -31,6 +37,10 @@ export function EditProfileModal({
onSave,
}: EditProfileModalProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const sheetRef = useRef<BottomSheet>(null);
const snapPoints = useMemo(() => ['95%'], []);
const [displayName, setDisplayName] = useState(initialProfile.displayName);
const [email, setEmail] = useState(initialProfile.email);
const [avatar, setAvatar] = useState<AvatarEmoji>(initialProfile.avatar);
@ -40,6 +50,9 @@ export function EditProfileModal({
setDisplayName(initialProfile.displayName);
setEmail(initialProfile.email);
setAvatar(initialProfile.avatar);
sheetRef.current?.snapToIndex(0);
} else {
sheetRef.current?.close();
}
}, [visible, initialProfile]);
@ -62,26 +75,36 @@ export function EditProfileModal({
}
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
<BottomSheet
ref={sheetRef}
index={-1}
snapPoints={snapPoints}
topInset={insets.top + 8}
enableDynamicSizing={false}
enablePanDownToClose
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
android_keyboardInputMode="adjustResize"
onClose={onClose}
backgroundStyle={styles.background}
handleIndicatorStyle={styles.handle}
containerStyle={styles.container}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.overlay}
<BottomSheetScrollView
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + 24 },
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<Pressable style={styles.backdrop} onPress={onClose} />
<View style={styles.card}>
<View style={styles.header}>
<Text style={styles.title}>{t('profile.editTitle')}</Text>
<Pressable onPress={onClose} hitSlop={10}>
<X size={20} color={colors.neutral[600]} />
<Pressable onPress={onClose} hitSlop={10} style={styles.closeBtn}>
<X size={18} color={colors.neutral[600]} />
</Pressable>
</View>
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
<Text style={styles.fieldLabel}>{t('profile.avatarLabel')}</Text>
<View style={styles.avatarRow}>
{AVATAR_OPTIONS.map((option) => {
@ -90,7 +113,10 @@ export function EditProfileModal({
<Pressable
key={option}
onPress={() => setAvatar(option)}
style={[styles.avatarOption, selected && styles.avatarOptionSelected]}
style={[
styles.avatarOption,
selected && styles.avatarOptionSelected,
]}
>
<Text style={styles.avatarEmoji}>{option}</Text>
</Pressable>
@ -99,7 +125,7 @@ export function EditProfileModal({
</View>
<Text style={styles.fieldLabel}>{t('profile.nameField')}</Text>
<TextInput
<BottomSheetTextInput
style={styles.input}
value={displayName}
onChangeText={setDisplayName}
@ -110,7 +136,7 @@ export function EditProfileModal({
/>
<Text style={styles.fieldLabel}>{t('profile.emailField')}</Text>
<TextInput
<BottomSheetTextInput
style={styles.input}
value={email}
onChangeText={setEmail}
@ -123,7 +149,6 @@ export function EditProfileModal({
returnKeyType="done"
onSubmitEditing={handleSave}
/>
</ScrollView>
<View style={styles.actions}>
<Pressable
@ -134,7 +159,12 @@ export function EditProfileModal({
pressed && { opacity: 0.7 },
]}
>
<Text style={styles.buttonGhostLabel}>{t('common.cancel')}</Text>
<View style={styles.buttonInner}>
<X size={18} color={colors.neutral[800]} strokeWidth={2.4} />
<RNText style={styles.buttonGhostLabel}>
{t('common.cancel')}
</RNText>
</View>
</Pressable>
<Pressable
onPress={handleSave}
@ -144,34 +174,44 @@ export function EditProfileModal({
pressed && { opacity: 0.85 },
]}
>
<Text style={styles.buttonPrimaryLabel}>{t('profile.saveButton')}</Text>
<View style={styles.buttonInner}>
<Check size={18} color="#FFFFFF" strokeWidth={2.6} />
<RNText style={styles.buttonPrimaryLabel}>
{t('profile.saveButton')}
</RNText>
</View>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
</BottomSheetScrollView>
</BottomSheet>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 24,
container: {
zIndex: 100,
elevation: 100,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.45)',
},
card: {
width: '100%',
maxWidth: 420,
background: {
backgroundColor: '#FFFFFF',
borderRadius: 24,
padding: 20,
gap: 14,
maxHeight: '90%',
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
shadowColor: '#000',
shadowOffset: { width: 0, height: -8 },
shadowOpacity: 0.18,
shadowRadius: 24,
elevation: 24,
},
handle: {
backgroundColor: colors.neutral[300],
width: 40,
height: 4,
},
content: {
paddingHorizontal: 20,
paddingTop: 4,
paddingBottom: 20,
gap: 12,
},
header: {
flexDirection: 'row',
@ -183,6 +223,14 @@ const styles = StyleSheet.create({
fontWeight: '700',
color: colors.neutral[900],
},
closeBtn: {
width: 32,
height: 32,
borderRadius: 999,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.neutral[100],
},
fieldLabel: {
fontSize: 12,
fontWeight: '600',
@ -220,39 +268,55 @@ const styles = StyleSheet.create({
input: {
borderWidth: 1,
borderColor: colors.neutral[300],
borderRadius: 12,
borderRadius: 14,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
paddingVertical: 14,
fontSize: 16,
color: colors.neutral[900],
backgroundColor: '#FAFAFA',
},
actions: {
flexDirection: 'row',
gap: 10,
marginTop: 12,
marginTop: 20,
},
button: {
flex: 1,
paddingVertical: 12,
borderRadius: 12,
paddingVertical: 16,
borderRadius: 14,
minHeight: 56,
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 6,
},
buttonInner: {
flexDirection: 'row',
alignItems: 'center',
},
buttonGhost: {
backgroundColor: colors.neutral[100],
backgroundColor: colors.neutral[200],
borderWidth: 1.5,
borderColor: colors.neutral[400],
},
buttonGhostLabel: {
fontSize: 15,
fontWeight: '600',
fontSize: 16,
fontWeight: '700',
color: colors.neutral[800],
letterSpacing: 0.2,
marginLeft: 8,
},
buttonPrimary: {
backgroundColor: colors.primary[800],
shadowColor: colors.primary[900],
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 4,
},
buttonPrimaryLabel: {
fontSize: 15,
fontWeight: '600',
fontSize: 16,
fontWeight: '700',
color: '#FFFFFF',
letterSpacing: 0.2,
marginLeft: 8,
},
});