From 07f25769e3ad0ab5d829d8babc179a2a92b0a1ac Mon Sep 17 00:00:00 2001 From: Yanis Date: Fri, 1 May 2026 00:36:44 +0200 Subject: [PATCH] refactor(bottom-sheets): polish edit sheets (buttons, keyboard, layout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EditNameBottomSheet : - Render conditionnel (mounted seulement quand editingName=true) pour ne plus ouvrir le clavier auto à l'arrivée sur ScanDetail - BottomSheetScrollView avec contentContainerStyle inline (Tailwind pour le reste) + insets.bottom pour padding bas - isDirty disable du Save quand le nom n'a pas changé - Boutons Annuler/Enregistrer en row via inner View avec icônes X/Check, font-bold, minHeight 56px EditProfileModal : - BottomSheetScrollView : actions déplacées DANS le scroll (juste après le dernier input email) → toujours visibles sous le formulaire, jamais poussées en bas du sheet - snap 95% + topInset safe-area - Boutons même style (icônes + ghost grisé bordé + primary shadow) MapBottomSheet rename inline : - useImperativeHandle pour forwarder le ref correctement et avoir un internalRef accessible côté composant - snapToIndex(2) à l'ouverture du form (85%) puis snapToIndex(0) après save/cancel pour redonner la map - BottomSheetScrollView pour le rename form avec keyboardShouldPersistTaps - keyboardBehavior 'interactive' + android_keyboardInputMode 'adjustResize' - containerStyle { zIndex:100, elevation:100 } pour passer au-dessus des FloatingActions Co-Authored-By: Claude Opus 4.7 (1M context) --- VinEye/src/components/map/MapBottomSheet.tsx | 636 ++++++------------ .../my-plants/EditNameBottomSheet.tsx | 175 ++--- .../components/profile/EditProfileModal.tsx | 365 ++++------ 3 files changed, 453 insertions(+), 723 deletions(-) diff --git a/VinEye/src/components/map/MapBottomSheet.tsx b/VinEye/src/components/map/MapBottomSheet.tsx index c374113..0124bd1 100644 --- a/VinEye/src/components/map/MapBottomSheet.tsx +++ b/VinEye/src/components/map/MapBottomSheet.tsx @@ -6,12 +6,7 @@ import { useRef, useState, } from "react"; -import { - View, - Pressable, - StyleSheet, - Text as RNText, -} from "react-native"; +import { View, Pressable } from "react-native"; import BottomSheet, { BottomSheetScrollView, BottomSheetTextInput, @@ -86,190 +81,226 @@ export const MapBottomSheet = forwardRef( } }, [renamingScan, t]); + const trimmedDraft = draftName.trim(); + const initialDraft = renamingScan + ? getScanDisplayName(renamingScan, t).trim() + : ""; + const isDraftDirty = + trimmedDraft.length > 0 && trimmedDraft !== initialDraft; + function handleStartRename(scan: ScanRecord) { setRenamingScan(scan); - // remonte à 85% pour bien voir l'input + boutons au-dessus du clavier internalRef.current?.snapToIndex(2); } function handleConfirmRename() { + if (!isDraftDirty) return; if (renamingScan) { - onRename?.(renamingScan.id, draftName); + onRename?.(renamingScan.id, trimmedDraft); } setRenamingScan(null); setDraftName(""); - // redescend pour voir la map internalRef.current?.snapToIndex(0); } function handleCancelRename() { setRenamingScan(null); setDraftName(""); - // redescend pour voir la map internalRef.current?.snapToIndex(0); } return ( - <> - - {renamingScan ? ( - - - - - - {t("map.rename.title")} - - - - {t("map.rename.subtitle")} + + {renamingScan ? ( + + + + + + + {t("map.rename.title")} - + + + {t("map.rename.subtitle")} + + + + + + + + {t("common.cancel")} + + + + + + + + {t("map.rename.save")} + + + + + + ) : previewScan ? ( + + + + {t("map.preview.title")} + + + + + + + onScanPress?.(previewScan)} + onEdit={() => handleStartRename(previewScan)} /> - - [ - styles.renameBtn, - styles.renameBtnGhost, - pressed && { opacity: 0.7 }, - ]} - > - - - - {t("common.cancel")} - - - - [ - styles.renameBtn, - styles.renameBtnPrimary, - pressed && { opacity: 0.85 }, - ]} - > - - - - {t("map.rename.save")} - - - - - - ) : previewScan ? ( - - - {t("map.preview.title")} - - - - - - onScanPress?.(previewScan)} - onEdit={() => handleStartRename(previewScan)} - /> - - {t("map.preview.tapHint")} - - + + {t("map.preview.tapHint")} + + + + ) : ( + <> + + + {t("map.scannedPlants")} + + + {t("map.plantCount", { count: scans.length })} + - ) : ( - <> - - {t("map.scannedPlants")} - - {t("map.plantCount", { count: scans.length })} - - - {scans.length === 0 ? ( - - - + {scans.length === 0 ? ( + + + + + + {t("map.empty.title")} + + + {t("map.empty.subtitle")} + + {onScanCta && ( + + + + {t("map.empty.cta")} + + + )} - {t("map.empty.title")} - {t("map.empty.subtitle")} - {onScanCta && ( - [ - styles.emptyCta, - pressed && { opacity: 0.85 }, - ]} - > - - {t("map.empty.cta")} - - )} - - ) : ( - - {scans.map((scan, index) => ( - onScanPress?.(scan)} - onEdit={() => handleStartRename(scan)} - /> - ))} - - )} - - )} - - + ) : ( + + {scans.map((scan, index) => ( + onScanPress?.(scan)} + onEdit={() => handleStartRename(scan)} + /> + ))} + + )} + + )} + ); - } + }, ); const STATUS_TINT: Record = { @@ -301,23 +332,40 @@ function ScanRow({ scan, isLast, onPress, onEdit }: ScanRowProps) { }); return ( - - - + + + - - + + {displayName} - + {formattedDate} - + @@ -326,249 +374,3 @@ function ScanRow({ scan, isLast, onPress, onEdit }: ScanRowProps) { ); } - -const styles = StyleSheet.create({ - sheetContainer: { - zIndex: 100, - elevation: 100, - }, - background: { - backgroundColor: "#FFFFFF", - borderTopLeftRadius: 28, - borderTopRightRadius: 28, - shadowColor: "#000", - shadowOffset: { width: 0, height: -4 }, - shadowOpacity: 0.08, - shadowRadius: 12, - elevation: 12, - }, - handleIndicator: { - backgroundColor: colors.neutral[300], - width: 40, - height: 4, - }, - header: { - flexDirection: "row", - alignItems: "baseline", - justifyContent: "space-between", - paddingHorizontal: 20, - paddingTop: 8, - paddingBottom: 12, - }, - previewHeader: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingHorizontal: 20, - paddingTop: 8, - paddingBottom: 4, - }, - previewCloseBtn: { - width: 32, - height: 32, - borderRadius: 999, - alignItems: "center", - justifyContent: "center", - backgroundColor: colors.neutral[100], - }, - previewBody: { - paddingHorizontal: 20, - paddingBottom: 20, - gap: 8, - }, - previewHint: { - fontSize: 12, - fontWeight: "500", - color: colors.neutral[500], - textAlign: "center", - paddingTop: 4, - }, - title: { - fontSize: 18, - fontWeight: "700", - color: colors.neutral[900], - }, - count: { - fontSize: 13, - fontWeight: "500", - color: colors.neutral[500], - }, - listContent: { - paddingHorizontal: 20, - paddingBottom: 24, - }, - row: { - flexDirection: "row", - alignItems: "center", - gap: 8, - paddingVertical: 14, - }, - rowBorder: { - borderBottomWidth: 1, - borderBottomColor: colors.neutral[200], - }, - rowMain: { - flex: 1, - flexDirection: "row", - alignItems: "center", - gap: 14, - }, - iconBadge: { - width: 48, - height: 48, - borderRadius: 14, - alignItems: "center", - justifyContent: "center", - }, - rowText: { - flex: 1, - gap: 2, - }, - location: { - fontSize: 15, - fontWeight: "700", - color: colors.neutral[900], - }, - region: { - fontSize: 13, - color: colors.neutral[600], - }, - editPencil: { - width: 32, - height: 32, - borderRadius: 999, - alignItems: "center", - justifyContent: "center", - backgroundColor: colors.neutral[100], - }, - emptyState: { - alignItems: "center", - paddingHorizontal: 32, - paddingVertical: 24, - gap: 8, - }, - emptyIconWrap: { - width: 64, - height: 64, - borderRadius: 20, - backgroundColor: colors.primary[100], - alignItems: "center", - justifyContent: "center", - marginBottom: 8, - }, - emptyTitle: { - fontSize: 16, - fontWeight: "700", - color: colors.neutral[900], - textAlign: "center", - }, - emptySubtitle: { - fontSize: 13, - color: colors.neutral[600], - textAlign: "center", - lineHeight: 18, - marginBottom: 4, - }, - emptyCta: { - flexDirection: "row", - alignItems: "center", - gap: 8, - backgroundColor: colors.primary[800], - paddingHorizontal: 22, - paddingVertical: 12, - borderRadius: 999, - marginTop: 8, - }, - emptyCtaLabel: { - color: "#FFFFFF", - fontSize: 14, - fontWeight: "600", - }, - // Rename inline form - renameWrap: { - paddingHorizontal: 20, - paddingTop: 4, - paddingBottom: 24, - gap: 12, - }, - renameHeader: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - }, - backBtn: { - width: 36, - height: 36, - borderRadius: 999, - alignItems: "center", - justifyContent: "center", - backgroundColor: colors.neutral[100], - }, - backBtnPlaceholder: { - width: 36, - height: 36, - }, - renameSubtitle: { - fontSize: 13, - color: colors.neutral[600], - lineHeight: 18, - paddingHorizontal: 4, - }, - renameInput: { - borderWidth: 1, - borderColor: colors.neutral[300], - borderRadius: 14, - paddingHorizontal: 14, - paddingVertical: 14, - fontSize: 16, - color: colors.neutral[900], - backgroundColor: "#FAFAFA", - }, - renameActions: { - flexDirection: "row", - paddingTop: 16, - gap: 12, - }, - renameBtn: { - flexGrow: 1, - flexShrink: 1, - flexBasis: 0, - paddingVertical: 16, - paddingHorizontal: 12, - borderRadius: 14, - minHeight: 56, - alignItems: "center", - justifyContent: "center", - }, - renameBtnInner: { - flexDirection: "row", - alignItems: "center", - }, - renameBtnGhost: { - backgroundColor: colors.neutral[200], - borderWidth: 1.5, - borderColor: colors.neutral[400], - }, - renameBtnGhostLabel: { - fontSize: 16, - fontWeight: "700", - color: colors.neutral[800], - letterSpacing: 0.2, - marginLeft: 8, - }, - renameBtnPrimary: { - backgroundColor: colors.primary[800], - shadowColor: colors.primary[900], - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.25, - shadowRadius: 8, - elevation: 4, - }, - renameBtnPrimaryLabel: { - fontSize: 16, - fontWeight: "700", - color: "#FFFFFF", - letterSpacing: 0.2, - marginLeft: 8, - }, -}); diff --git a/VinEye/src/components/my-plants/EditNameBottomSheet.tsx b/VinEye/src/components/my-plants/EditNameBottomSheet.tsx index 856987e..d661165 100644 --- a/VinEye/src/components/my-plants/EditNameBottomSheet.tsx +++ b/VinEye/src/components/my-plants/EditNameBottomSheet.tsx @@ -3,11 +3,10 @@ import { View, Pressable, Modal, - KeyboardAvoidingView, - Platform, TextInput, TouchableWithoutFeedback, Keyboard, + Animated, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTranslation } from "react-i18next"; @@ -30,11 +29,25 @@ export function EditNameBottomSheet({ const { t } = useTranslation(); const insets = useSafeAreaInsets(); const [name, setName] = useState(initialName); + const [keyboardHeight, setKeyboardHeight] = useState(0); useEffect(() => { setName(initialName); }, [initialName]); + useEffect(() => { + const showSub = Keyboard.addListener("keyboardDidShow", (e) => { + setKeyboardHeight(e.endCoordinates.height); + }); + const hideSub = Keyboard.addListener("keyboardDidHide", () => { + setKeyboardHeight(0); + }); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + const trimmedName = name.trim(); const isDirty = trimmedName.length > 0 && trimmedName !== initialName.trim(); @@ -44,97 +57,97 @@ export function EditNameBottomSheet({ onSave(trimmedName); } + // On Android edge-to-edge, the keyboard sits on top of content. We add + // its height as bottom padding so the buttons stay visible above it. + const bottomPadding = + keyboardHeight > 0 ? keyboardHeight + 75 : insets.bottom + 34; + return ( - + - - - - - - - - - {t("myPlants.detail.renameTitle")} - - - - - - - - {t("myPlants.detail.renameSubtitle")} - - - - - - - - - - {t("myPlants.actions.cancel")} - - - - - - - - {t("myPlants.detail.renameSave")} - - - - + + + - - + + + + {t("myPlants.detail.renameTitle")} + + + + + + + + {t("myPlants.detail.renameSubtitle")} + + + + + + + + + + {t("myPlants.actions.cancel")} + + + + + + + + {t("myPlants.detail.renameSave")} + + + + + + ); } diff --git a/VinEye/src/components/profile/EditProfileModal.tsx b/VinEye/src/components/profile/EditProfileModal.tsx index b1f7b4e..067a86b 100644 --- a/VinEye/src/components/profile/EditProfileModal.tsx +++ b/VinEye/src/components/profile/EditProfileModal.tsx @@ -1,10 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { - View, - Pressable, - StyleSheet, - Text as RNText, -} from 'react-native'; +import { View, Pressable } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import BottomSheet, { BottomSheetScrollView, @@ -56,9 +51,15 @@ export function EditProfileModal({ } }, [visible, initialProfile]); + const trimmedName = displayName.trim(); + const trimmedEmail = email.trim(); + const isDirty = + trimmedName !== initialProfile.displayName.trim() || + trimmedEmail !== initialProfile.email.trim() || + avatar !== initialProfile.avatar; + function handleSave() { - const trimmedName = displayName.trim(); - const trimmedEmail = email.trim(); + if (!isDirty) return; if (trimmedEmail.length > 0 && !isValidEmail(trimmedEmail)) { toast.error(t('profile.invalidEmail')); @@ -86,237 +87,151 @@ export function EditProfileModal({ keyboardBlurBehavior="restore" android_keyboardInputMode="adjustResize" onClose={onClose} - backgroundStyle={styles.background} - handleIndicatorStyle={styles.handle} - containerStyle={styles.container} + backgroundStyle={{ + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + }} + handleIndicatorStyle={{ + backgroundColor: colors.neutral[300], + width: 40, + height: 4, + }} + containerStyle={{ zIndex: 100, elevation: 100 }} > - - {t('profile.editTitle')} - + + + {t('profile.editTitle')} + + - {t('profile.avatarLabel')} - - {AVATAR_OPTIONS.map((option) => { - const selected = option === avatar; - return ( - setAvatar(option)} - style={[ - styles.avatarOption, - selected && styles.avatarOptionSelected, - ]} + + {t('profile.avatarLabel')} + + + {AVATAR_OPTIONS.map((option) => { + const selected = option === avatar; + return ( + setAvatar(option)} + className={`w-[52px] h-[52px] rounded-full items-center justify-center border-2 ${ + selected + ? 'bg-[#E9F5EC] border-primary' + : 'bg-[#F8F9FA] border-transparent' + }`} + > + - {option} - - ); - })} - + {option} + + + ); + })} + - {t('profile.nameField')} - + + {t('profile.nameField')} + + - {t('profile.emailField')} - + + {t('profile.emailField')} + + - - [ - styles.button, - styles.buttonGhost, - pressed && { opacity: 0.7 }, - ]} - > - - - - {t('common.cancel')} - - - - [ - styles.button, - styles.buttonPrimary, - pressed && { opacity: 0.85 }, - ]} - > - - - - {t('profile.saveButton')} - - - - + + + + + + {t('common.cancel')} + + + + + + + + {t('profile.saveButton')} + + + + ); } - -const styles = StyleSheet.create({ - container: { - zIndex: 100, - elevation: 100, - }, - background: { - backgroundColor: '#FFFFFF', - 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', - alignItems: 'center', - justifyContent: 'space-between', - }, - title: { - fontSize: 18, - 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', - color: colors.neutral[500], - textTransform: 'uppercase', - letterSpacing: 1, - marginTop: 12, - marginBottom: 8, - }, - avatarRow: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 10, - }, - avatarOption: { - width: 52, - height: 52, - borderRadius: 999, - backgroundColor: '#F8F9FA', - alignItems: 'center', - justifyContent: 'center', - borderWidth: 2, - borderColor: 'transparent', - }, - avatarOptionSelected: { - backgroundColor: colors.primary[100], - borderColor: colors.primary[800], - }, - avatarEmoji: { - fontSize: 28, - lineHeight: 36, - textAlign: 'center', - includeFontPadding: false, - }, - input: { - borderWidth: 1, - borderColor: colors.neutral[300], - borderRadius: 14, - paddingHorizontal: 14, - paddingVertical: 14, - fontSize: 16, - color: colors.neutral[900], - backgroundColor: '#FAFAFA', - }, - actions: { - flexDirection: 'row', - marginTop: 20, - }, - button: { - flex: 1, - paddingVertical: 16, - borderRadius: 14, - minHeight: 56, - alignItems: 'center', - justifyContent: 'center', - marginHorizontal: 6, - }, - buttonInner: { - flexDirection: 'row', - alignItems: 'center', - }, - buttonGhost: { - backgroundColor: colors.neutral[200], - borderWidth: 1.5, - borderColor: colors.neutral[400], - }, - buttonGhostLabel: { - 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: 16, - fontWeight: '700', - color: '#FFFFFF', - letterSpacing: 0.2, - marginLeft: 8, - }, -});