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:
parent
0d97be422e
commit
0ce7b3a718
|
|
@ -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,68 +75,80 @@ 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>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t('profile.editTitle')}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={10} style={styles.closeBtn}>
|
||||
<X size={18} color={colors.neutral[600]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<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>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
|
||||
<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>
|
||||
<Text style={styles.fieldLabel}>{t('profile.nameField')}</Text>
|
||||
<BottomSheetTextInput
|
||||
style={styles.input}
|
||||
value={displayName}
|
||||
onChangeText={setDisplayName}
|
||||
placeholder={t('profile.namePlaceholder')}
|
||||
placeholderTextColor={colors.neutral[400]}
|
||||
maxLength={64}
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<Text style={styles.fieldLabel}>{t('profile.nameField')}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={displayName}
|
||||
onChangeText={setDisplayName}
|
||||
placeholder={t('profile.namePlaceholder')}
|
||||
placeholderTextColor={colors.neutral[400]}
|
||||
maxLength={64}
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<Text style={styles.fieldLabel}>{t('profile.emailField')}</Text>
|
||||
<TextInput
|
||||
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>
|
||||
<Text style={styles.fieldLabel}>{t('profile.emailField')}</Text>
|
||||
<BottomSheetTextInput
|
||||
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}
|
||||
/>
|
||||
|
||||
<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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue