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 { import {
View, View,
Modal,
Pressable, Pressable,
TextInput,
StyleSheet, StyleSheet,
KeyboardAvoidingView, Text as RNText,
Platform,
ScrollView,
} from 'react-native'; } 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 { useTranslation } from 'react-i18next';
import { X } from 'lucide-react-native'; import { X, Check } from 'lucide-react-native';
import { toast } from 'sonner-native'; import { toast } from 'sonner-native';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { colors } from '@/theme/colors'; 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 { interface EditProfileModalProps {
visible: boolean; visible: boolean;
@ -31,6 +37,10 @@ export function EditProfileModal({
onSave, onSave,
}: EditProfileModalProps) { }: EditProfileModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets();
const sheetRef = useRef<BottomSheet>(null);
const snapPoints = useMemo(() => ['95%'], []);
const [displayName, setDisplayName] = useState(initialProfile.displayName); const [displayName, setDisplayName] = useState(initialProfile.displayName);
const [email, setEmail] = useState(initialProfile.email); const [email, setEmail] = useState(initialProfile.email);
const [avatar, setAvatar] = useState<AvatarEmoji>(initialProfile.avatar); const [avatar, setAvatar] = useState<AvatarEmoji>(initialProfile.avatar);
@ -40,6 +50,9 @@ export function EditProfileModal({
setDisplayName(initialProfile.displayName); setDisplayName(initialProfile.displayName);
setEmail(initialProfile.email); setEmail(initialProfile.email);
setAvatar(initialProfile.avatar); setAvatar(initialProfile.avatar);
sheetRef.current?.snapToIndex(0);
} else {
sheetRef.current?.close();
} }
}, [visible, initialProfile]); }, [visible, initialProfile]);
@ -62,68 +75,80 @@ export function EditProfileModal({
} }
return ( return (
<Modal <BottomSheet
visible={visible} ref={sheetRef}
transparent index={-1}
animationType="fade" snapPoints={snapPoints}
onRequestClose={onClose} 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 <BottomSheetScrollView
behavior={Platform.OS === 'ios' ? 'padding' : undefined} contentContainerStyle={[
style={styles.overlay} styles.content,
{ paddingBottom: insets.bottom + 24 },
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
> >
<Pressable style={styles.backdrop} onPress={onClose} /> <View style={styles.header}>
<View style={styles.card}> <Text style={styles.title}>{t('profile.editTitle')}</Text>
<View style={styles.header}> <Pressable onPress={onClose} hitSlop={10} style={styles.closeBtn}>
<Text style={styles.title}>{t('profile.editTitle')}</Text> <X size={18} color={colors.neutral[600]} />
<Pressable onPress={onClose} hitSlop={10}> </Pressable>
<X size={20} color={colors.neutral[600]} /> </View>
</Pressable>
<Text style={styles.fieldLabel}>{t('profile.avatarLabel')}</Text>
<View style={styles.avatarRow}>
{AVATAR_OPTIONS.map((option) => {
const selected = option === avatar;
return (
<Pressable
key={option}
onPress={() => setAvatar(option)}
style={[
styles.avatarOption,
selected && styles.avatarOptionSelected,
]}
>
<Text style={styles.avatarEmoji}>{option}</Text>
</Pressable>
);
})}
</View> </View>
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled"> <Text style={styles.fieldLabel}>{t('profile.nameField')}</Text>
<Text style={styles.fieldLabel}>{t('profile.avatarLabel')}</Text> <BottomSheetTextInput
<View style={styles.avatarRow}> style={styles.input}
{AVATAR_OPTIONS.map((option) => { value={displayName}
const selected = option === avatar; onChangeText={setDisplayName}
return ( placeholder={t('profile.namePlaceholder')}
<Pressable placeholderTextColor={colors.neutral[400]}
key={option} maxLength={64}
onPress={() => setAvatar(option)} returnKeyType="next"
style={[styles.avatarOption, selected && styles.avatarOptionSelected]} />
>
<Text style={styles.avatarEmoji}>{option}</Text>
</Pressable>
);
})}
</View>
<Text style={styles.fieldLabel}>{t('profile.nameField')}</Text> <Text style={styles.fieldLabel}>{t('profile.emailField')}</Text>
<TextInput <BottomSheetTextInput
style={styles.input} style={styles.input}
value={displayName} value={email}
onChangeText={setDisplayName} onChangeText={setEmail}
placeholder={t('profile.namePlaceholder')} placeholder={t('profile.emailPlaceholder')}
placeholderTextColor={colors.neutral[400]} placeholderTextColor={colors.neutral[400]}
maxLength={64} keyboardType="email-address"
returnKeyType="next" autoCapitalize="none"
/> autoCorrect={false}
maxLength={128}
<Text style={styles.fieldLabel}>{t('profile.emailField')}</Text> returnKeyType="done"
<TextInput onSubmitEditing={handleSave}
style={styles.input} />
value={email}
onChangeText={setEmail}
placeholder={t('profile.emailPlaceholder')}
placeholderTextColor={colors.neutral[400]}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
maxLength={128}
returnKeyType="done"
onSubmitEditing={handleSave}
/>
</ScrollView>
<View style={styles.actions}> <View style={styles.actions}>
<Pressable <Pressable
@ -134,7 +159,12 @@ export function EditProfileModal({
pressed && { opacity: 0.7 }, 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>
<Pressable <Pressable
onPress={handleSave} onPress={handleSave}
@ -144,34 +174,44 @@ export function EditProfileModal({
pressed && { opacity: 0.85 }, 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> </Pressable>
</View> </View>
</View> </BottomSheetScrollView>
</KeyboardAvoidingView> </BottomSheet>
</Modal>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overlay: { container: {
flex: 1, zIndex: 100,
justifyContent: 'center', elevation: 100,
alignItems: 'center',
paddingHorizontal: 24,
}, },
backdrop: { background: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.45)',
},
card: {
width: '100%',
maxWidth: 420,
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
borderRadius: 24, borderTopLeftRadius: 28,
padding: 20, borderTopRightRadius: 28,
gap: 14, shadowColor: '#000',
maxHeight: '90%', 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: { header: {
flexDirection: 'row', flexDirection: 'row',
@ -183,6 +223,14 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
color: colors.neutral[900], color: colors.neutral[900],
}, },
closeBtn: {
width: 32,
height: 32,
borderRadius: 999,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.neutral[100],
},
fieldLabel: { fieldLabel: {
fontSize: 12, fontSize: 12,
fontWeight: '600', fontWeight: '600',
@ -220,39 +268,55 @@ const styles = StyleSheet.create({
input: { input: {
borderWidth: 1, borderWidth: 1,
borderColor: colors.neutral[300], borderColor: colors.neutral[300],
borderRadius: 12, borderRadius: 14,
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 12, paddingVertical: 14,
fontSize: 15, fontSize: 16,
color: colors.neutral[900], color: colors.neutral[900],
backgroundColor: '#FAFAFA', backgroundColor: '#FAFAFA',
}, },
actions: { actions: {
flexDirection: 'row', flexDirection: 'row',
gap: 10, marginTop: 20,
marginTop: 12,
}, },
button: { button: {
flex: 1, flex: 1,
paddingVertical: 12, paddingVertical: 16,
borderRadius: 12, borderRadius: 14,
minHeight: 56,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginHorizontal: 6,
},
buttonInner: {
flexDirection: 'row',
alignItems: 'center',
}, },
buttonGhost: { buttonGhost: {
backgroundColor: colors.neutral[100], backgroundColor: colors.neutral[200],
borderWidth: 1.5,
borderColor: colors.neutral[400],
}, },
buttonGhostLabel: { buttonGhostLabel: {
fontSize: 15, fontSize: 16,
fontWeight: '600', fontWeight: '700',
color: colors.neutral[800], color: colors.neutral[800],
letterSpacing: 0.2,
marginLeft: 8,
}, },
buttonPrimary: { buttonPrimary: {
backgroundColor: colors.primary[800], backgroundColor: colors.primary[800],
shadowColor: colors.primary[900],
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 4,
}, },
buttonPrimaryLabel: { buttonPrimaryLabel: {
fontSize: 15, fontSize: 16,
fontWeight: '600', fontWeight: '700',
color: '#FFFFFF', color: '#FFFFFF',
letterSpacing: 0.2,
marginLeft: 8,
}, },
}); });