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:
Yanis 2026-05-01 00:03:32 +02:00
parent 98c446cd35
commit 0d97be422e
3 changed files with 206 additions and 4 deletions

View 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>
);
}

View file

@ -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,
};
} }

View file

@ -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>
); );
} }