refactor(bottom-sheets): polish edit sheets (buttons, keyboard, layout)
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) <noreply@anthropic.com>
This commit is contained in:
parent
28d4fc176e
commit
07f25769e3
|
|
@ -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,38 +81,50 @@ export const MapBottomSheet = forwardRef<BottomSheet, MapBottomSheetProps>(
|
|||
}
|
||||
}, [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 (
|
||||
<>
|
||||
<BottomSheet
|
||||
ref={internalRef}
|
||||
index={defaultIndex}
|
||||
snapPoints={snapPoints}
|
||||
handleIndicatorStyle={styles.handleIndicator}
|
||||
backgroundStyle={styles.background}
|
||||
containerStyle={styles.sheetContainer}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: colors.neutral[300],
|
||||
width: 40,
|
||||
height: 4,
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
}}
|
||||
containerStyle={{ zIndex: 100, elevation: 100 }}
|
||||
enableDynamicSizing={false}
|
||||
enablePanDownToClose={false}
|
||||
keyboardBehavior="interactive"
|
||||
|
|
@ -126,25 +133,29 @@ export const MapBottomSheet = forwardRef<BottomSheet, MapBottomSheetProps>(
|
|||
>
|
||||
{renamingScan ? (
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={[
|
||||
styles.renameWrap,
|
||||
{ paddingBottom: 24 },
|
||||
]}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 24,
|
||||
gap: 12,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.renameHeader}>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Pressable
|
||||
onPress={handleCancelRename}
|
||||
hitSlop={10}
|
||||
style={styles.backBtn}
|
||||
className="w-9 h-9 rounded-full items-center justify-center bg-[#FAFAFA]"
|
||||
>
|
||||
<ChevronLeft size={20} color={colors.neutral[700]} />
|
||||
</Pressable>
|
||||
<Text style={styles.title}>{t("map.rename.title")}</Text>
|
||||
<View style={styles.backBtnPlaceholder} />
|
||||
<Text className="text-lg font-bold text-[#1B1B1B]">
|
||||
{t("map.rename.title")}
|
||||
</Text>
|
||||
<View className="w-9 h-9" />
|
||||
</View>
|
||||
<Text style={styles.renameSubtitle}>
|
||||
<Text className="text-[13px] leading-[18px] text-[#6B6B6B] px-1">
|
||||
{t("map.rename.subtitle")}
|
||||
</Text>
|
||||
<BottomSheetTextInput
|
||||
|
|
@ -156,101 +167,122 @@ export const MapBottomSheet = forwardRef<BottomSheet, MapBottomSheetProps>(
|
|||
maxLength={64}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleConfirmRename}
|
||||
style={styles.renameInput}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colors.neutral[300],
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 14,
|
||||
fontSize: 16,
|
||||
color: colors.neutral[900],
|
||||
backgroundColor: "#FAFAFA",
|
||||
}}
|
||||
/>
|
||||
<View style={styles.renameActions}>
|
||||
<View className="flex-row pt-4 gap-3">
|
||||
<Pressable
|
||||
onPress={handleCancelRename}
|
||||
style={({ pressed }) => [
|
||||
styles.renameBtn,
|
||||
styles.renameBtnGhost,
|
||||
pressed && { opacity: 0.7 },
|
||||
]}
|
||||
className="flex-1 min-h-[56px] rounded-[14px] py-4 px-3 items-center justify-center bg-[#F5F5F5] border-[1.5px] border-[#BDBDBD] active:opacity-70"
|
||||
>
|
||||
<View style={styles.renameBtnInner}>
|
||||
<X
|
||||
size={18}
|
||||
color={colors.neutral[800]}
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
<RNText style={styles.renameBtnGhostLabel}>
|
||||
<View className="flex-row items-center">
|
||||
<X size={18} color={colors.neutral[800]} strokeWidth={2.4} />
|
||||
<Text className="text-base font-bold text-[#2D2D2D] ml-2 tracking-[0.2px]">
|
||||
{t("common.cancel")}
|
||||
</RNText>
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleConfirmRename}
|
||||
style={({ pressed }) => [
|
||||
styles.renameBtn,
|
||||
styles.renameBtnPrimary,
|
||||
pressed && { opacity: 0.85 },
|
||||
]}
|
||||
disabled={!isDraftDirty}
|
||||
className={
|
||||
isDraftDirty
|
||||
? "flex-1 min-h-[56px] rounded-[14px] py-4 px-3 items-center justify-center bg-primary active:opacity-85"
|
||||
: "flex-1 min-h-[56px] rounded-[14px] py-4 px-3 items-center justify-center bg-primary/40"
|
||||
}
|
||||
>
|
||||
<View style={styles.renameBtnInner}>
|
||||
<Check size={18} color="#FFFFFF" strokeWidth={2.6} />
|
||||
<RNText style={styles.renameBtnPrimaryLabel}>
|
||||
<View className="flex-row items-center">
|
||||
<Check
|
||||
size={18}
|
||||
color="#FFFFFF"
|
||||
strokeWidth={2.6}
|
||||
opacity={isDraftDirty ? 1 : 0.7}
|
||||
/>
|
||||
<Text className="text-base font-bold text-white ml-2 tracking-[0.2px]">
|
||||
{t("map.rename.save")}
|
||||
</RNText>
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
) : previewScan ? (
|
||||
<View>
|
||||
<View style={styles.previewHeader}>
|
||||
<Text style={styles.title}>{t("map.preview.title")}</Text>
|
||||
<View className="flex-row items-center justify-between px-5 pt-2 pb-1">
|
||||
<Text className="text-lg font-bold text-[#1B1B1B]">
|
||||
{t("map.preview.title")}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={onPreviewClose}
|
||||
hitSlop={10}
|
||||
style={styles.previewCloseBtn}
|
||||
className="w-8 h-8 rounded-full items-center justify-center bg-[#FAFAFA]"
|
||||
>
|
||||
<X size={18} color={colors.neutral[600]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.previewBody}>
|
||||
<View className="px-5 pb-5 gap-2">
|
||||
<ScanRow
|
||||
scan={previewScan}
|
||||
isLast
|
||||
onPress={() => onScanPress?.(previewScan)}
|
||||
onEdit={() => handleStartRename(previewScan)}
|
||||
/>
|
||||
<Text style={styles.previewHint}>
|
||||
<Text className="text-xs font-medium text-[#9E9E9E] text-center pt-1">
|
||||
{t("map.preview.tapHint")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("map.scannedPlants")}</Text>
|
||||
<Text style={styles.count}>
|
||||
<View className="flex-row items-baseline justify-between px-5 pt-2 pb-3">
|
||||
<Text className="text-lg font-bold text-[#1B1B1B]">
|
||||
{t("map.scannedPlants")}
|
||||
</Text>
|
||||
<Text className="text-[13px] font-medium text-[#9E9E9E]">
|
||||
{t("map.plantCount", { count: scans.length })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{scans.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.emptyIconWrap}>
|
||||
<MapPin size={32} color={colors.primary[800]} strokeWidth={2} />
|
||||
<View className="items-center px-8 py-6 gap-2">
|
||||
<View className="w-16 h-16 rounded-2xl bg-[#E9F5EC] items-center justify-center mb-2">
|
||||
<MapPin
|
||||
size={32}
|
||||
color={colors.primary[800]}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>{t("map.empty.title")}</Text>
|
||||
<Text style={styles.emptySubtitle}>{t("map.empty.subtitle")}</Text>
|
||||
<Text className="text-base font-bold text-[#1B1B1B] text-center">
|
||||
{t("map.empty.title")}
|
||||
</Text>
|
||||
<Text className="text-[13px] text-[#6B6B6B] text-center leading-[18px] mb-1">
|
||||
{t("map.empty.subtitle")}
|
||||
</Text>
|
||||
{onScanCta && (
|
||||
<Pressable
|
||||
onPress={onScanCta}
|
||||
style={({ pressed }) => [
|
||||
styles.emptyCta,
|
||||
pressed && { opacity: 0.85 },
|
||||
]}
|
||||
className="flex-row items-center gap-2 bg-primary px-5 py-3 rounded-full mt-2 active:opacity-85"
|
||||
>
|
||||
<ScanLine size={18} color="#FFFFFF" strokeWidth={2.2} />
|
||||
<Text style={styles.emptyCtaLabel}>{t("map.empty.cta")}</Text>
|
||||
<Text className="text-white text-sm font-semibold">
|
||||
{t("map.empty.cta")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.listContent}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 24,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{scans.map((scan, index) => (
|
||||
|
|
@ -267,9 +299,8 @@ export const MapBottomSheet = forwardRef<BottomSheet, MapBottomSheetProps>(
|
|||
</>
|
||||
)}
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const STATUS_TINT: Record<ScanStatus, { bg: string; fg: string }> = {
|
||||
|
|
@ -301,23 +332,40 @@ function ScanRow({ scan, isLast, onPress, onEdit }: ScanRowProps) {
|
|||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.row, !isLast && styles.rowBorder]}>
|
||||
<Pressable onPress={onPress} style={styles.rowMain}>
|
||||
<View style={[styles.iconBadge, { backgroundColor: tint.bg }]}>
|
||||
<View
|
||||
className={`flex-row items-center gap-2 py-3.5 ${
|
||||
!isLast ? "border-b border-[#F5F5F5]" : ""
|
||||
}`}
|
||||
>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className="flex-1 flex-row items-center gap-3.5"
|
||||
>
|
||||
<View
|
||||
className="w-12 h-12 rounded-2xl items-center justify-center"
|
||||
style={{ backgroundColor: tint.bg }}
|
||||
>
|
||||
<Icon size={22} color={tint.fg} strokeWidth={2.2} />
|
||||
</View>
|
||||
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.location} numberOfLines={1}>
|
||||
<View className="flex-1 gap-0.5">
|
||||
<Text
|
||||
className="text-[15px] font-bold text-[#1B1B1B]"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text style={styles.region} numberOfLines={1}>
|
||||
<Text className="text-[13px] text-[#6B6B6B]" numberOfLines={1}>
|
||||
{formattedDate}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<Pressable onPress={onEdit} hitSlop={8} style={styles.editPencil}>
|
||||
<Pressable
|
||||
onPress={onEdit}
|
||||
hitSlop={8}
|
||||
className="w-8 h-8 rounded-full items-center justify-center bg-[#FAFAFA]"
|
||||
>
|
||||
<Pencil size={16} color={colors.neutral[600]} strokeWidth={2} />
|
||||
</Pressable>
|
||||
<Pressable onPress={onPress} hitSlop={8}>
|
||||
|
|
@ -326,249 +374,3 @@ function ScanRow({ scan, isLast, onPress, onEdit }: ScanRowProps) {
|
|||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,26 +57,27 @@ 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 (
|
||||
<Modal
|
||||
visible
|
||||
transparent
|
||||
animationType="slide"
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
className="flex-1 justify-end"
|
||||
>
|
||||
<View className="flex-1 justify-end">
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<View className="absolute inset-0 bg-black/40" />
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<View
|
||||
<Animated.View
|
||||
className="bg-white rounded-t-[28px] px-5 pt-3"
|
||||
style={{ paddingBottom: insets.bottom + 24 }}
|
||||
style={{ paddingBottom: bottomPadding }}
|
||||
>
|
||||
<View className="items-center pb-2">
|
||||
<View className="w-10 h-1 rounded-full bg-[#E0E0E0]" />
|
||||
|
|
@ -132,9 +146,8 @@ export function EditNameBottomSheet({
|
|||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
function handleSave() {
|
||||
const trimmedName = displayName.trim();
|
||||
const trimmedEmail = email.trim();
|
||||
const isDirty =
|
||||
trimmedName !== initialProfile.displayName.trim() ||
|
||||
trimmedEmail !== initialProfile.email.trim() ||
|
||||
avatar !== initialProfile.avatar;
|
||||
|
||||
function handleSave() {
|
||||
if (!isDirty) return;
|
||||
|
||||
if (trimmedEmail.length > 0 && !isValidEmail(trimmedEmail)) {
|
||||
toast.error(t('profile.invalidEmail'));
|
||||
|
|
@ -86,58 +87,94 @@ 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 }}
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{ paddingBottom: insets.bottom + 24 },
|
||||
]}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 4,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
gap: 12,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t('profile.editTitle')}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={10} style={styles.closeBtn}>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-lg font-bold text-[#1B1B1B]">
|
||||
{t('profile.editTitle')}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
hitSlop={10}
|
||||
className="w-8 h-8 rounded-full items-center justify-center bg-[#FAFAFA]"
|
||||
>
|
||||
<X size={18} color={colors.neutral[600]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={styles.fieldLabel}>{t('profile.avatarLabel')}</Text>
|
||||
<View style={styles.avatarRow}>
|
||||
<Text className="text-xs font-semibold text-[#9E9E9E] uppercase tracking-[1px] mt-3 mb-2">
|
||||
{t('profile.avatarLabel')}
|
||||
</Text>
|
||||
<View className="flex-row flex-wrap gap-2.5">
|
||||
{AVATAR_OPTIONS.map((option) => {
|
||||
const selected = option === avatar;
|
||||
return (
|
||||
<Pressable
|
||||
key={option}
|
||||
onPress={() => setAvatar(option)}
|
||||
style={[
|
||||
styles.avatarOption,
|
||||
selected && styles.avatarOptionSelected,
|
||||
]}
|
||||
className={`w-[52px] h-[52px] rounded-full items-center justify-center border-2 ${
|
||||
selected
|
||||
? 'bg-[#E9F5EC] border-primary'
|
||||
: 'bg-[#F8F9FA] border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Text style={styles.avatarEmoji}>{option}</Text>
|
||||
<Text
|
||||
className="text-[28px] text-center"
|
||||
style={{ lineHeight: 36, includeFontPadding: false }}
|
||||
>
|
||||
{option}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<Text style={styles.fieldLabel}>{t('profile.nameField')}</Text>
|
||||
<Text className="text-xs font-semibold text-[#9E9E9E] uppercase tracking-[1px] mt-3 mb-2">
|
||||
{t('profile.nameField')}
|
||||
</Text>
|
||||
<BottomSheetTextInput
|
||||
style={styles.input}
|
||||
value={displayName}
|
||||
onChangeText={setDisplayName}
|
||||
placeholder={t('profile.namePlaceholder')}
|
||||
placeholderTextColor={colors.neutral[400]}
|
||||
maxLength={64}
|
||||
returnKeyType="next"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colors.neutral[300],
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 14,
|
||||
fontSize: 16,
|
||||
color: colors.neutral[900],
|
||||
backgroundColor: '#FAFAFA',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text style={styles.fieldLabel}>{t('profile.emailField')}</Text>
|
||||
<Text className="text-xs font-semibold text-[#9E9E9E] uppercase tracking-[1px] mt-3 mb-2">
|
||||
{t('profile.emailField')}
|
||||
</Text>
|
||||
<BottomSheetTextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder={t('profile.emailPlaceholder')}
|
||||
|
|
@ -148,124 +185,7 @@ export function EditProfileModal({
|
|||
maxLength={128}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSave}
|
||||
/>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
styles.buttonGhost,
|
||||
pressed && { opacity: 0.7 },
|
||||
]}
|
||||
>
|
||||
<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}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
styles.buttonPrimary,
|
||||
pressed && { opacity: 0.85 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.buttonInner}>
|
||||
<Check size={18} color="#FFFFFF" strokeWidth={2.6} />
|
||||
<RNText style={styles.buttonPrimaryLabel}>
|
||||
{t('profile.saveButton')}
|
||||
</RNText>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
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: {
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colors.neutral[300],
|
||||
borderRadius: 14,
|
||||
|
|
@ -274,49 +194,44 @@ const styles = StyleSheet.create({
|
|||
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,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<View className="flex-row pt-5 gap-3">
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
className="flex-1 min-h-[56px] rounded-[14px] py-4 px-3 items-center justify-center bg-[#F5F5F5] border-[1.5px] border-[#BDBDBD] active:opacity-70"
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<X size={18} color={colors.neutral[800]} strokeWidth={2.4} />
|
||||
<Text className="text-base font-bold text-[#2D2D2D] ml-2 tracking-[0.2px]">
|
||||
{t('common.cancel')}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleSave}
|
||||
disabled={!isDirty}
|
||||
className={
|
||||
isDirty
|
||||
? 'flex-1 min-h-[56px] rounded-[14px] py-4 px-3 items-center justify-center bg-primary active:opacity-85'
|
||||
: 'flex-1 min-h-[56px] rounded-[14px] py-4 px-3 items-center justify-center bg-primary/40'
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Check
|
||||
size={18}
|
||||
color="#FFFFFF"
|
||||
strokeWidth={2.6}
|
||||
opacity={isDirty ? 1 : 0.7}
|
||||
/>
|
||||
<Text className="text-base font-bold text-white ml-2 tracking-[0.2px]">
|
||||
{t('profile.saveButton')}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue