feat(home): skeleton loading states + cascade entrance animations

Animations FadeInDown.springify().damping(16) avec stagger 60ms entre
sections du HomeScreen :
- SearchHeader (delay 0)
- SearchSection (delay 60)
- RecentScans (delay 120)
- FrequentDiseasesHorizontal (delay 200)
- PracticalGuides (delay 280)

Skeleton loading states :
- LargeDiseaseCardCompactSkeleton : nouveau, simule la structure compact
  (badge + icon + title + desc + footer) — utilisé dans
  FrequentDiseasesHorizontal en remplacement de CarouselCardSkeleton
- ScanListItemSkeleton : nouveau, simule image 64x64 + name + status pill
  + time + confidence tile — utilisé dans RecentScans
- RecentScans / PracticalGuides : nouveau style cardLoading sans
  shadow/elevation Android (qui ne respecte pas l'opacité de FadeInDown
  → flash "rectangle blanc + ombre" pendant l'anim). iOS shadow conservé.

isLoading propagé depuis useDiseases / useGuides / useHistory vers les
sections concernées.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yanis 2026-05-01 01:05:37 +02:00
parent 25b56092d5
commit 07c42ed40e
5 changed files with 153 additions and 17 deletions

View file

@ -3,7 +3,7 @@ import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import LargeDiseaseCard from "@/components/guides/LargeDiseaseCard";
import { CarouselCardSkeleton } from "@/components/ui/Skeleton";
import { LargeDiseaseCardCompactSkeleton } from "@/components/ui/Skeleton";
import type { Disease } from "@/data/diseases";
import type { RootStackParamList } from "@/types/navigation";
@ -24,10 +24,21 @@ export default function FrequentDiseasesHorizontal({
if (isLoading && diseases.length === 0) {
return (
<View className="flex-row gap-4 px-5">
<CarouselCardSkeleton />
<CarouselCardSkeleton />
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 20,
gap: 12,
paddingVertical: 8,
}}
>
{[0, 1, 2].map((k) => (
<View key={k} style={{ width: CARD_WIDTH }}>
<LargeDiseaseCardCompactSkeleton />
</View>
))}
</ScrollView>
);
}

View file

@ -20,7 +20,7 @@ export default function PracticalGuides({ guides, isLoading }: PracticalGuidesPr
if (isLoading && items.length === 0) {
return (
<View style={styles.card}>
<View style={styles.cardLoading}>
<GuideListItemSkeleton />
<GuideListItemSkeleton />
<GuideListItemSkeleton />
@ -60,4 +60,13 @@ const styles = StyleSheet.create({
android: { elevation: 2 },
}),
},
// Loading: pas de shadow / elevation → évite le flash "rectangle blanc + ombre"
// sur Android avant que les skeletons ne fadent in via FadeInDown du parent.
cardLoading: {
backgroundColor: "#FFFFFF",
borderRadius: 16,
overflow: "hidden",
borderWidth: 1,
borderColor: "#F0F0F0",
},
});

View file

@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { ScanListItem } from "@/components/my-plants/ScanListItem";
import SectionHeader from "@/components/home/components/homeheader";
import HomeCta from "@/components/home/HomeCta";
import { ScanListItemSkeleton } from "@/components/ui/Skeleton";
import { useHistory } from "@/hooks/useHistory";
import type { RootStackParamList } from "@/types/navigation";
@ -16,7 +17,24 @@ const MAX_RECENT = 3;
export default function RecentScans() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
const { history, toggleFavorite, deleteScan } = useHistory();
const { history, isLoading, toggleFavorite, deleteScan } = useHistory();
// Loading + pas encore de cache → skeleton
if (isLoading && history.length === 0) {
return (
<View className="mb-6 mx-5 gap-3">
<SectionHeader
title={t("home.recentScans")}
onViewAll={() => navigation.navigate("Main", { screen: "MyPlants" })}
/>
<View style={styles.cardLoading}>
<ScanListItemSkeleton showSeparator />
<ScanListItemSkeleton showSeparator />
<ScanListItemSkeleton />
</View>
</View>
);
}
if (history.length === 0) {
return <HomeCta />;
@ -64,4 +82,13 @@ const styles = StyleSheet.create({
android: { elevation: 2 },
}),
},
// Loading: pas de shadow / elevation → évite le flash "rectangle blanc + ombre"
// sur Android avant que les skeletons ne fadent in via FadeInDown du parent.
cardLoading: {
backgroundColor: "#FFFFFF",
borderRadius: 16,
overflow: "hidden",
borderWidth: 1,
borderColor: "#F0F0F0",
},
});

View file

@ -69,6 +69,45 @@ export function GuideListItemSkeleton() {
);
}
export function LargeDiseaseCardCompactSkeleton() {
return (
<View style={skeletonStyles.compactCard}>
<View style={skeletonStyles.compactHeader}>
<Skeleton width={70} height={22} borderRadius={999} />
<Skeleton width={36} height={36} borderRadius={999} />
</View>
<View style={{ gap: 8, marginTop: 16 }}>
<Skeleton width="80%" height={20} borderRadius={6} />
<Skeleton width="100%" height={12} borderRadius={4} />
<Skeleton width="70%" height={12} borderRadius={4} />
</View>
<View style={skeletonStyles.compactFooter}>
<Skeleton width={80} height={24} borderRadius={999} />
<Skeleton width={40} height={40} borderRadius={999} />
</View>
</View>
);
}
export function ScanListItemSkeleton({
showSeparator = false,
}: { showSeparator?: boolean } = {}) {
return (
<View>
<View style={skeletonStyles.scanRow}>
<Skeleton width={64} height={64} borderRadius={16} />
<View style={{ flex: 1, marginLeft: 12, gap: 6 }}>
<Skeleton width="70%" height={16} borderRadius={6} />
<Skeleton width={70} height={20} borderRadius={999} />
<Skeleton width="40%" height={12} borderRadius={4} />
</View>
<Skeleton width={44} height={44} borderRadius={999} />
</View>
{showSeparator && <View style={skeletonStyles.separator} />}
</View>
);
}
const skeletonStyles = StyleSheet.create({
listItem: {
flexDirection: "row",
@ -80,6 +119,41 @@ const skeletonStyles = StyleSheet.create({
listItemText: {
flex: 1,
},
compactCard: {
width: "100%",
minHeight: 220,
borderRadius: 24,
backgroundColor: "#FFFFFF",
borderWidth: 1,
borderColor: "#EEEEEE",
padding: 20,
justifyContent: "space-between",
},
compactHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
compactFooter: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 16,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: "#F5F5F5",
},
scanRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 14,
paddingHorizontal: 16,
},
separator: {
height: 1,
backgroundColor: "#F0F0F0",
marginLeft: 92,
},
});
const styles = StyleSheet.create({

View file

@ -3,6 +3,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTranslation } from "react-i18next";
import Animated, { FadeInDown } from "react-native-reanimated";
import type { RootStackParamList } from "@/types/navigation";
import { useDiseases } from "@/hooks/useDiseases";
@ -17,11 +18,16 @@ import RecentScans from "@/components/home/RecentScans";
type Nav = NativeStackNavigationProp<RootStackParamList>;
const ENTER_DURATION = 420;
function entering(delay: number) {
return FadeInDown.delay(delay).duration(ENTER_DURATION).springify().damping(16);
}
export default function HomeScreen() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
const { data: diseases, isLoading: diseasesLoading } = useDiseases();
const { data: guides } = useGuides();
const { data: guides, isLoading: guidesLoading } = useGuides();
return (
<SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}>
@ -30,34 +36,43 @@ export default function HomeScreen() {
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 24 }}
>
<SearchHeader />
<Animated.View entering={entering(0)}>
<SearchHeader />
</Animated.View>
<SearchSection />
<Animated.View entering={entering(60)}>
<SearchSection />
</Animated.View>
<RecentScans />
<Animated.View entering={entering(120)}>
<RecentScans />
</Animated.View>
{/* Frequent diseases carousel */}
<View className="mb-6 gap-3">
<Animated.View entering={entering(200)} className="mb-6 gap-3">
<View className="px-5">
<SectionHeader
title={t("home.frequentDiseases")}
onViewAll={() => navigation.navigate("Main", { screen: "Guides" })}
/>
</View>
<FrequentDiseasesHorizontal diseases={diseases} isLoading={diseasesLoading} />
</View>
<FrequentDiseasesHorizontal
diseases={diseases}
isLoading={diseasesLoading}
/>
</Animated.View>
{/* Season alert — désactivé tant que la page Notifications n'est pas prête */}
{/* <SeasonAlert /> */}
{/* Practical guides */}
<View className="mx-5 mb-6 gap-3">
<Animated.View entering={entering(280)} className="mx-5 mb-6 gap-3">
<SectionHeader
title={t("home.practicalGuides")}
onViewAll={() => navigation.navigate("Main", { screen: "Guides" })}
/>
<PracticalGuides guides={guides} />
</View>
<PracticalGuides guides={guides} isLoading={guidesLoading} />
</Animated.View>
<View className="h-8" />
</ScrollView>