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 type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import LargeDiseaseCard from "@/components/guides/LargeDiseaseCard"; 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 { Disease } from "@/data/diseases";
import type { RootStackParamList } from "@/types/navigation"; import type { RootStackParamList } from "@/types/navigation";
@ -24,10 +24,21 @@ export default function FrequentDiseasesHorizontal({
if (isLoading && diseases.length === 0) { if (isLoading && diseases.length === 0) {
return ( return (
<View className="flex-row gap-4 px-5"> <ScrollView
<CarouselCardSkeleton /> horizontal
<CarouselCardSkeleton /> showsHorizontalScrollIndicator={false}
</View> 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) { if (isLoading && items.length === 0) {
return ( return (
<View style={styles.card}> <View style={styles.cardLoading}>
<GuideListItemSkeleton /> <GuideListItemSkeleton />
<GuideListItemSkeleton /> <GuideListItemSkeleton />
<GuideListItemSkeleton /> <GuideListItemSkeleton />
@ -60,4 +60,13 @@ const styles = StyleSheet.create({
android: { elevation: 2 }, 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 { ScanListItem } from "@/components/my-plants/ScanListItem";
import SectionHeader from "@/components/home/components/homeheader"; import SectionHeader from "@/components/home/components/homeheader";
import HomeCta from "@/components/home/HomeCta"; import HomeCta from "@/components/home/HomeCta";
import { ScanListItemSkeleton } from "@/components/ui/Skeleton";
import { useHistory } from "@/hooks/useHistory"; import { useHistory } from "@/hooks/useHistory";
import type { RootStackParamList } from "@/types/navigation"; import type { RootStackParamList } from "@/types/navigation";
@ -16,7 +17,24 @@ const MAX_RECENT = 3;
export default function RecentScans() { export default function RecentScans() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = useNavigation<Nav>(); 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) { if (history.length === 0) {
return <HomeCta />; return <HomeCta />;
@ -64,4 +82,13 @@ const styles = StyleSheet.create({
android: { elevation: 2 }, 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({ const skeletonStyles = StyleSheet.create({
listItem: { listItem: {
flexDirection: "row", flexDirection: "row",
@ -80,6 +119,41 @@ const skeletonStyles = StyleSheet.create({
listItemText: { listItemText: {
flex: 1, 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({ 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 { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Animated, { FadeInDown } from "react-native-reanimated";
import type { RootStackParamList } from "@/types/navigation"; import type { RootStackParamList } from "@/types/navigation";
import { useDiseases } from "@/hooks/useDiseases"; import { useDiseases } from "@/hooks/useDiseases";
@ -17,11 +18,16 @@ import RecentScans from "@/components/home/RecentScans";
type Nav = NativeStackNavigationProp<RootStackParamList>; 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() { export default function HomeScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const { data: diseases, isLoading: diseasesLoading } = useDiseases(); const { data: diseases, isLoading: diseasesLoading } = useDiseases();
const { data: guides } = useGuides(); const { data: guides, isLoading: guidesLoading } = useGuides();
return ( return (
<SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}> <SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}>
@ -30,34 +36,43 @@ export default function HomeScreen() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 24 }} 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 */} {/* Frequent diseases carousel */}
<View className="mb-6 gap-3"> <Animated.View entering={entering(200)} className="mb-6 gap-3">
<View className="px-5"> <View className="px-5">
<SectionHeader <SectionHeader
title={t("home.frequentDiseases")} title={t("home.frequentDiseases")}
onViewAll={() => navigation.navigate("Main", { screen: "Guides" })} onViewAll={() => navigation.navigate("Main", { screen: "Guides" })}
/> />
</View> </View>
<FrequentDiseasesHorizontal diseases={diseases} isLoading={diseasesLoading} /> <FrequentDiseasesHorizontal
</View> diseases={diseases}
isLoading={diseasesLoading}
/>
</Animated.View>
{/* Season alert — désactivé tant que la page Notifications n'est pas prête */} {/* Season alert — désactivé tant que la page Notifications n'est pas prête */}
{/* <SeasonAlert /> */} {/* <SeasonAlert /> */}
{/* Practical guides */} {/* Practical guides */}
<View className="mx-5 mb-6 gap-3"> <Animated.View entering={entering(280)} className="mx-5 mb-6 gap-3">
<SectionHeader <SectionHeader
title={t("home.practicalGuides")} title={t("home.practicalGuides")}
onViewAll={() => navigation.navigate("Main", { screen: "Guides" })} onViewAll={() => navigation.navigate("Main", { screen: "Guides" })}
/> />
<PracticalGuides guides={guides} /> <PracticalGuides guides={guides} isLoading={guidesLoading} />
</View> </Animated.View>
<View className="h-8" /> <View className="h-8" />
</ScrollView> </ScrollView>