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:
parent
07c42ed40e
commit
05ea1df6ff
|
|
@ -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: {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
133
VinEye/src/components/ui/ConfirmDialog.tsx
Normal file
133
VinEye/src/components/ui/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue