feat(my-plants): skeleton + animations + ConfirmDialog (fix double modal)

ConfirmDialog (nouveau composant ui réutilisable) :
- Modal RN avec backdrop noir 50%, card rounded-3xl + shadow
- Variant 'destructive' (icône AlertTriangle rouge) ou 'default' (Check vert)
- Boutons côte-à-côte avec icônes X/Trash2/Check, minHeight 52px,
  ghost grisé bordé + primary/destructive avec shadow
- Tap backdrop = cancel, tap dialog = no-op via stopPropagation

ScanListItem :
- Remplace Alert.alert natif par ConfirmDialog stylé pour la suppression
- Le swipe gauche → bouton Supprimer → un seul modal ConfirmDialog → confirm

MyPlantsScreen :
- Suppression du DOUBLE modal (Alert dans handleDeleteScan était redondant
  avec celui de ScanListItem → 2 modals successifs avant suppression)
  → handleDeleteScan appelle directement deleteScan(id)
- FadeInDown.springify().damping(18) sur header / SearchBar / chaque
  date group avec stagger index*60
- Skeleton loading state : 2 groupes simulés (header bar + 3
  ScanListItemSkeleton dans une card-loading sans elevation)

DateGroupAccordion :
- Retrait de l'elevation Android sur styles.card → fix le flash "rectangle
  blanc + ombre" pendant l'animation FadeInDown du parent. iOS shadow
  conservée (composite layer respecte l'opacité)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yanis 2026-05-01 01:05:54 +02:00
parent 07c42ed40e
commit 05ea1df6ff
4 changed files with 240 additions and 50 deletions

View file

@ -115,6 +115,9 @@ const styles = StyleSheet.create({
paddingBottom: 8,
paddingHorizontal: 20,
},
// Pas d'elevation sur Android : l'ombre native ne respecte pas l'opacité
// de FadeInDown du parent → flash "rectangle blanc + ombre" pendant l'anim.
// iOS shadow conservé : pas de soucis de composite layer là-bas.
card: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
@ -128,7 +131,7 @@ const styles = StyleSheet.create({
shadowOpacity: 0.04,
shadowRadius: 8,
},
android: { elevation: 2 },
android: {},
}),
},
});

View file

@ -1,5 +1,5 @@
import { useRef } from 'react';
import { View, Text, TouchableOpacity, Alert, StyleSheet } from 'react-native';
import { useRef, useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
import { useTranslation } from 'react-i18next';
import { Image } from 'expo-image';
@ -8,6 +8,7 @@ import { toast } from 'sonner-native';
import { ConfidenceTile } from '@/components/my-plants/ConfidenceTile';
import { StatusTag } from '@/components/my-plants/StatusTag';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { getCepageById } from '@/utils/cepages';
import { hapticLight, hapticSuccess } from '@/services/haptics';
import { colors } from '@/theme/colors';
@ -58,6 +59,7 @@ export function ScanListItem({
}: ScanListItemProps) {
const { t } = useTranslation();
const swipeableRef = useRef<Swipeable>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const isFav = scan.isFavorite === true;
const status = getScanStatus(scan);
const hasImage = !!scan.detection.imageUri;
@ -70,23 +72,15 @@ export function ScanListItem({
}
function handleDelete() {
Alert.alert(
t('myPlants.actions.deleteConfirmTitle'),
t('myPlants.actions.deleteConfirmMessage'),
[
{ text: t('myPlants.actions.cancel'), style: 'cancel' },
{
text: t('myPlants.actions.delete'),
style: 'destructive',
onPress: () => {
setConfirmOpen(true);
swipeableRef.current?.close();
}
function handleConfirmDelete() {
setConfirmOpen(false);
onDelete();
hapticSuccess();
toast.success(t('myPlants.toasts.deleted'));
},
},
],
);
swipeableRef.current?.close();
}
function renderRightActions() {
@ -119,6 +113,7 @@ export function ScanListItem({
}
return (
<>
<Swipeable
ref={swipeableRef}
renderRightActions={renderRightActions}
@ -171,6 +166,18 @@ export function ScanListItem({
</TouchableOpacity>
{grouped && showSeparator && <View style={styles.separator} />}
</Swipeable>
<ConfirmDialog
visible={confirmOpen}
title={t('myPlants.actions.deleteConfirmTitle')}
message={t('myPlants.actions.deleteConfirmMessage')}
confirmLabel={t('myPlants.actions.delete')}
cancelLabel={t('myPlants.actions.cancel')}
variant="destructive"
onConfirm={handleConfirmDelete}
onCancel={() => setConfirmOpen(false)}
/>
</>
);
}

View file

@ -0,0 +1,133 @@
import { Modal, Pressable, View } from "react-native";
import { useTranslation } from "react-i18next";
import { X, Check, Trash2, AlertTriangle } from "lucide-react-native";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
type Variant = "default" | "destructive";
interface ConfirmDialogProps {
visible: boolean;
title: string;
message?: string;
confirmLabel: string;
cancelLabel?: string;
variant?: Variant;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
visible,
title,
message,
confirmLabel,
cancelLabel,
variant = "default",
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { t } = useTranslation();
const cancelText = cancelLabel ?? t("common.cancel");
const isDestructive = variant === "destructive";
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onCancel}
statusBarTranslucent
>
<Pressable
onPress={onCancel}
className="flex-1 items-center justify-center px-6 bg-black/50"
>
<Pressable
onPress={(e) => e.stopPropagation()}
className="w-full max-w-[400px] bg-white rounded-3xl p-6"
style={{
shadowColor: "#000",
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.18,
shadowRadius: 28,
elevation: 16,
}}
>
{/* Icon header */}
<View className="items-center mb-4">
<View
className={`w-14 h-14 rounded-full items-center justify-center ${
isDestructive ? "bg-[#FBE9E7]" : "bg-[#E8F5E9]"
}`}
>
{isDestructive ? (
<AlertTriangle
size={26}
color="#D32F2F"
strokeWidth={2.4}
/>
) : (
<Check size={26} color={colors.primary[700]} strokeWidth={2.6} />
)}
</View>
</View>
{/* Title */}
<Text className="text-[18px] font-bold text-[#1A1A1A] text-center">
{title}
</Text>
{/* Message */}
{message ? (
<Text className="mt-2 text-[14px] leading-5 text-[#6B6B6B] text-center">
{message}
</Text>
) : null}
{/* Actions */}
<View className="flex-row mt-6">
<Pressable
onPress={onCancel}
className="flex-1 min-h-[52px] rounded-[14px] py-3 px-3 items-center justify-center bg-[#F2F2F2] border border-[#E0E0E0] active:opacity-70"
style={{ marginRight: 6 }}
>
<View className="flex-row items-center">
<X size={16} color={colors.neutral[800]} strokeWidth={2.4} />
<Text className="text-[15px] font-bold text-[#2D2D2D] ml-2">
{cancelText}
</Text>
</View>
</Pressable>
<Pressable
onPress={onConfirm}
className={`flex-1 min-h-[52px] rounded-[14px] py-3 px-3 items-center justify-center active:opacity-85 ${
isDestructive ? "bg-[#D32F2F]" : "bg-[#2D6A4F]"
}`}
style={{
marginLeft: 6,
shadowColor: isDestructive ? "#D32F2F" : colors.primary[900],
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 4,
}}
>
<View className="flex-row items-center">
{isDestructive ? (
<Trash2 size={16} color="#FFFFFF" strokeWidth={2.4} />
) : (
<Check size={16} color="#FFFFFF" strokeWidth={2.6} />
)}
<Text className="text-[15px] font-bold text-white ml-2">
{confirmLabel}
</Text>
</View>
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
);
}

View file

@ -3,7 +3,6 @@ import {
View,
FlatList,
TouchableOpacity,
Alert,
StyleSheet,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@ -12,11 +11,13 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useTranslation } from 'react-i18next';
import { Image } from 'expo-image';
import { ScanLine } from 'lucide-react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { Text } from '@/components/ui/text';
import { DateGroupAccordion } from '@/components/my-plants/DateGroupAccordion';
import { HeaderActionButtons } from '@/components/shared/HeaderActionButtons';
import SearchBar from '@/components/shared/SearchBar';
import { ScanListItemSkeleton } from '@/components/ui/Skeleton';
import { useHistory } from '@/hooks/useHistory';
import { groupScansByDate } from '@/utils/dateGrouping';
import type { DateGroupKey, DateGroup } from '@/utils/dateGrouping';
@ -24,6 +25,11 @@ import { colors } from '@/theme/colors';
import type { RootStackParamList } from '@/types/navigation';
import type { ScanRecord } from '@/types/detection';
const ENTER_DURATION = 380;
function entering(delay: number) {
return FadeInDown.delay(delay).duration(ENTER_DURATION).springify().damping(18);
}
type Nav = NativeStackNavigationProp<RootStackParamList>;
const DEFAULT_OPEN: Set<DateGroupKey> = new Set(['today', 'yesterday', 'thisWeek']);
@ -65,19 +71,10 @@ export default function MyPlantsScreen() {
navigation.navigate('ScanDetail', { scanId: scan.id });
}
// ScanListItem gère déjà sa propre ConfirmDialog → on appelle directement
// deleteScan ici sans afficher un second modal.
function handleDeleteScan(scanId: string) {
Alert.alert(
t('myPlants.actions.deleteConfirmTitle'),
t('myPlants.actions.deleteConfirmMessage'),
[
{ text: t('myPlants.actions.cancel'), style: 'cancel' },
{
text: t('myPlants.actions.delete'),
style: 'destructive',
onPress: () => deleteScan(scanId),
},
],
);
deleteScan(scanId);
}
async function handleRefresh() {
@ -86,8 +83,9 @@ export default function MyPlantsScreen() {
setRefreshing(false);
}
function renderGroup({ item }: { item: DateGroup }) {
function renderGroup({ item, index }: { item: DateGroup; index: number }) {
return (
<Animated.View entering={entering(index * 60)}>
<DateGroupAccordion
groupKey={item.key}
label={item.label}
@ -98,30 +96,50 @@ export default function MyPlantsScreen() {
onToggleFavorite={(id) => toggleFavorite(id)}
onDeleteScan={handleDeleteScan}
/>
</Animated.View>
);
}
const isEmpty = history.length === 0 && !isLoading;
const showSkeleton = isLoading && history.length === 0;
return (
<View style={[styles.root, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<Animated.View entering={entering(0)} style={styles.header}>
<Text style={styles.title}>{t('myPlants.title')}</Text>
<HeaderActionButtons />
</View>
</Animated.View>
{/* Search bar (trigger global SearchScreen) */}
<View style={styles.searchContainer}>
<Animated.View entering={entering(60)} style={styles.searchContainer}>
<SearchBar
placeholder={t('myPlants.searchPlaceholder')}
onTriggerPress={() => navigation.navigate('Search')}
/>
</View>
</Animated.View>
{/* Content */}
{isEmpty ? (
<View style={styles.emptyContainer}>
{showSkeleton ? (
<Animated.View
entering={entering(120)}
style={styles.skeletonGroups}
>
{[0, 1].map((g) => (
<View key={g} style={styles.skeletonGroup}>
<View style={styles.skeletonGroupHeader}>
<View style={styles.skeletonHeaderBar} />
</View>
<View style={styles.skeletonGroupCard}>
<ScanListItemSkeleton showSeparator />
<ScanListItemSkeleton showSeparator />
<ScanListItemSkeleton />
</View>
</View>
))}
</Animated.View>
) : isEmpty ? (
<Animated.View entering={entering(120)} style={styles.emptyContainer}>
<View style={styles.emptyIconWrapper}>
<Image
source={EMPTY_IMAGE}
@ -139,7 +157,7 @@ export default function MyPlantsScreen() {
<ScanLine size={18} color="#FFFFFF" />
<Text style={styles.emptyCtaText}>{t('myPlants.empty.cta')}</Text>
</TouchableOpacity>
</View>
</Animated.View>
) : (
<FlatList
data={groups}
@ -183,6 +201,35 @@ const styles = StyleSheet.create({
listContent: {
paddingBottom: 100,
},
// Skeleton (loading state)
skeletonGroups: {
paddingHorizontal: 0,
paddingBottom: 100,
},
skeletonGroup: {
marginBottom: 4,
},
skeletonGroupHeader: {
paddingHorizontal: 20,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: '#F0F0F0',
},
skeletonHeaderBar: {
width: 130,
height: 16,
borderRadius: 6,
backgroundColor: '#E5E7EB',
},
skeletonGroupCard: {
marginHorizontal: 20,
marginTop: 8,
borderRadius: 16,
overflow: 'hidden',
borderWidth: 1,
borderColor: '#F0F0F0',
backgroundColor: '#FFFFFF',
},
// Empty state
emptyContainer: {
flex: 1,