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 {
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue