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:
parent
25b56092d5
commit
07c42ed40e
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue