feat(scan-detail): edit plant name via bottom sheet
- Nouveau composant EditNameBottomSheet (gorhom BottomSheet + BottomSheetTextInput + BottomSheetScrollView) avec snap 92%, topInset safe-area, keyboardBehavior interactive, autoFocus - Mounted conditionnellement (state editingName) pour éviter que l'autoFocus ouvre le clavier dès l'arrivée sur ScanDetail - Boutons Annuler / Enregistrer avec icônes X / Check, ghost grisé bordé + primary shadow, alignés en row via inner View, isDirty disable du Save si le nom n'a pas changé - ScanDetailScreen : bouton Pencil flottant à côté du favori, heroTitle utilise customName en priorité - useScanDetail : nouvelle méthode renameScan(newName) avec persist storage Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
98c446cd35
commit
0d97be422e
140
VinEye/src/components/my-plants/EditNameBottomSheet.tsx
Normal file
140
VinEye/src/components/my-plants/EditNameBottomSheet.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Pressable,
|
||||||
|
Modal,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
TextInput,
|
||||||
|
TouchableWithoutFeedback,
|
||||||
|
Keyboard,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { X, Check } from "lucide-react-native";
|
||||||
|
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { colors } from "@/theme/colors";
|
||||||
|
|
||||||
|
interface EditNameBottomSheetProps {
|
||||||
|
initialName: string;
|
||||||
|
onSave: (newName: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditNameBottomSheet({
|
||||||
|
initialName,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: EditNameBottomSheetProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [name, setName] = useState(initialName);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setName(initialName);
|
||||||
|
}, [initialName]);
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const isDirty =
|
||||||
|
trimmedName.length > 0 && trimmedName !== initialName.trim();
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!isDirty) return;
|
||||||
|
onSave(trimmedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
className="flex-1 justify-end"
|
||||||
|
>
|
||||||
|
<TouchableWithoutFeedback onPress={onClose}>
|
||||||
|
<View className="absolute inset-0 bg-black/40" />
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
|
||||||
|
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||||
|
<View
|
||||||
|
className="bg-white rounded-t-[28px] px-5 pt-3"
|
||||||
|
style={{ paddingBottom: insets.bottom + 24 }}
|
||||||
|
>
|
||||||
|
<View className="items-center pb-2">
|
||||||
|
<View className="w-10 h-1 rounded-full bg-[#E0E0E0]" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
|
<Text className="text-lg font-bold text-[#1B1B1B]">
|
||||||
|
{t("myPlants.detail.renameTitle")}
|
||||||
|
</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 className="text-[13px] leading-[18px] text-[#6B6B6B] mb-3">
|
||||||
|
{t("myPlants.detail.renameSubtitle")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder={t("myPlants.detail.renamePlaceholder")}
|
||||||
|
placeholderTextColor={colors.neutral[400]}
|
||||||
|
autoFocus
|
||||||
|
maxLength={64}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={handleSave}
|
||||||
|
className="border border-[#E0E0E0] rounded-[14px] px-3.5 py-3.5 text-base text-[#1B1B1B] bg-[#FAFAFA]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="flex-row pt-4 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("myPlants.actions.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("myPlants.detail.renameSave")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -38,5 +38,33 @@ export function useScanDetail(scanId: string) {
|
||||||
await storage.set(storage.KEYS.SCAN_HISTORY, updated);
|
await storage.set(storage.KEYS.SCAN_HISTORY, updated);
|
||||||
}, [scanId]);
|
}, [scanId]);
|
||||||
|
|
||||||
return { scan, loading, error, toggleFavorite, deleteScan, refetch: load };
|
const renameScan = useCallback(
|
||||||
|
async (newName: string) => {
|
||||||
|
const trimmed = newName.trim();
|
||||||
|
const all = await storage.get<ScanRecord[]>(storage.KEYS.SCAN_HISTORY);
|
||||||
|
if (!all) return;
|
||||||
|
const updated = all.map((s) =>
|
||||||
|
s.id === scanId
|
||||||
|
? { ...s, customName: trimmed.length > 0 ? trimmed : undefined }
|
||||||
|
: s,
|
||||||
|
);
|
||||||
|
await storage.set(storage.KEYS.SCAN_HISTORY, updated);
|
||||||
|
setScan((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, customName: trimmed.length > 0 ? trimmed : undefined }
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[scanId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scan,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
toggleFavorite,
|
||||||
|
deleteScan,
|
||||||
|
renameScan,
|
||||||
|
refetch: load,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
Share2,
|
Share2,
|
||||||
Trash2,
|
Trash2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Pencil,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
|
|
@ -37,6 +38,7 @@ import Animated, {
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { EditNameBottomSheet } from "@/components/my-plants/EditNameBottomSheet";
|
||||||
import { useScanDetail } from "@/hooks/useScanDetail";
|
import { useScanDetail } from "@/hooks/useScanDetail";
|
||||||
import { getCepageById } from "@/utils/cepages";
|
import { getCepageById } from "@/utils/cepages";
|
||||||
import { hapticSuccess } from "@/services/haptics";
|
import { hapticSuccess } from "@/services/haptics";
|
||||||
|
|
@ -94,8 +96,9 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { scan, loading, error, toggleFavorite, deleteScan } =
|
const { scan, loading, error, toggleFavorite, deleteScan, renameScan } =
|
||||||
useScanDetail(scanId);
|
useScanDetail(scanId);
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
|
||||||
// Entry animation
|
// Entry animation
|
||||||
const contentY = useSharedValue(30);
|
const contentY = useSharedValue(30);
|
||||||
|
|
@ -160,11 +163,27 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
const isFav = scan.isFavorite === true;
|
const isFav = scan.isFavorite === true;
|
||||||
const hasImage = !!detection.imageUri;
|
const hasImage = !!detection.imageUri;
|
||||||
|
|
||||||
const heroTitle = cepage
|
const fallbackTitle = cepage
|
||||||
? cepage.name.fr
|
? cepage.name.fr
|
||||||
: detection.result === "vine"
|
: detection.result === "vine"
|
||||||
? t("myPlants.detail.results.vine")
|
? t("myPlants.detail.results.vine")
|
||||||
: t("myPlants.detail.results.unidentified");
|
: t("myPlants.detail.results.unidentified");
|
||||||
|
const heroTitle = scan.customName?.trim() || fallbackTitle;
|
||||||
|
|
||||||
|
function handleOpenRename() {
|
||||||
|
setEditingName(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseRename() {
|
||||||
|
setEditingName(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRenameSave(newName: string) {
|
||||||
|
await renameScan(newName);
|
||||||
|
setEditingName(false);
|
||||||
|
hapticSuccess();
|
||||||
|
toast.success(t("myPlants.toasts.renamed"));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleToggleFavorite() {
|
async function handleToggleFavorite() {
|
||||||
await toggleFavorite();
|
await toggleFavorite();
|
||||||
|
|
@ -281,6 +300,13 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
fill={isFav ? "#FFB800" : "none"}
|
fill={isFav ? "#FFB800" : "none"}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.floatingBtn, { top: insets.top + 8, right: 72 }]}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
onPress={handleOpenRename}
|
||||||
|
>
|
||||||
|
<Pencil size={18} color="#1A1A1A" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* ── Content ── */}
|
{/* ── Content ── */}
|
||||||
<Animated.View style={contentAnim}>
|
<Animated.View style={contentAnim}>
|
||||||
|
|
@ -440,6 +466,14 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{editingName && (
|
||||||
|
<EditNameBottomSheet
|
||||||
|
initialName={scan.customName ?? ""}
|
||||||
|
onSave={handleRenameSave}
|
||||||
|
onClose={handleCloseRename}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue