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 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue