feat(home): recent scans + horizontal disease cards

Refonte HomeScreen :
- 'Commencer votre collection' (HomeCta) → conditionnellement remplacé par
  les 3 derniers scans en mode \"grouped card\" via le nouveau composant
  RecentScans (fallback HomeCta si historique vide)
- 'Maladies fréquentes' → SmallDiseaseCard remplacé par LargeDiseaseCard
  en mode compact, en scroll horizontal (FrequentDiseasesHorizontal)
- Comment temporairement <SeasonAlert /> en attendant la page Notifications

LargeDiseaseCard : nouvelle prop compact qui réduit hauteur (260→220),
padding, font-sizes et lignes de description (3→2). Border + radius +
shadow Android forcés en style inline pour clip elevation correctement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yanis 2026-05-01 00:03:00 +02:00
parent 4ebbc692ff
commit 7457e64996
4 changed files with 237 additions and 122 deletions

View file

@ -1,94 +1,162 @@
import { View, Pressable, StyleSheet } from 'react-native'; import { View, Pressable, StyleSheet } from "react-native";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from "@expo/vector-icons";
import { ArrowRight, AlertTriangle } from 'lucide-react-native'; import { ArrowRight, AlertTriangle } from "lucide-react-native";
import Animated, { FadeInDown } from 'react-native-reanimated'; import Animated, { FadeInDown } from "react-native-reanimated";
import { Text } from '@/components/ui/text'; import { Text } from "@/components/ui/text";
import { colors } from '@/theme/colors'; import { colors } from "@/theme/colors";
import type { Disease } from '@/data/diseases'; import type { Disease } from "@/data/diseases";
interface LargeDiseaseCardProps { interface LargeDiseaseCardProps {
disease: Disease; disease: Disease;
onPress: () => void; onPress: () => void;
index?: number; index?: number;
compact?: boolean;
} }
const TYPE_TINT: Record<Disease['type'], { bg: string; fg: string }> = { const TYPE_TINT: Record<Disease["type"], { bg: string; fg: string }> = {
fungal: { bg: '#7B1FA2', fg: '#FFFFFF' }, fungal: { bg: "#7B1FA2", fg: "#FFFFFF" },
bacterial: { bg: '#C62828', fg: '#FFFFFF' }, bacterial: { bg: "#C62828", fg: "#FFFFFF" },
pest: { bg: '#EF6C00', fg: '#FFFFFF' }, pest: { bg: "#EF6C00", fg: "#FFFFFF" },
abiotic: { bg: '#1565C0', fg: '#FFFFFF' }, abiotic: { bg: "#1565C0", fg: "#FFFFFF" },
}; };
const SEVERITY_TINT: Record<Disease['severity'], { bg: string; fg: string; labelKey: string }> = { const SEVERITY_TINT: Record<
high: { bg: '#FBE9E7', fg: '#D32F2F', labelKey: 'guides.riskLevel.high' }, Disease["severity"],
medium: { bg: '#FFF8E1', fg: '#F9A825', labelKey: 'guides.riskLevel.medium' }, { bg: string; fg: string; labelKey: string }
low: { bg: '#E8F5E9', fg: '#2E7D32', labelKey: 'guides.riskLevel.low' }, > = {
high: { bg: "#FBE9E7", fg: "#D32F2F", labelKey: "guides.riskLevel.high" },
medium: { bg: "#FFF8E1", fg: "#F9A825", labelKey: "guides.riskLevel.medium" },
low: { bg: "#E8F5E9", fg: "#2E7D32", labelKey: "guides.riskLevel.low" },
}; };
const TYPE_LABEL: Record<Disease['type'], string> = { const TYPE_LABEL: Record<Disease["type"], string> = {
fungal: 'diseases.types.fungal', fungal: "diseases.types.fungal",
bacterial: 'diseases.types.bacterial', bacterial: "diseases.types.bacterial",
pest: 'diseases.types.pest', pest: "diseases.types.pest",
abiotic: 'diseases.types.abiotic', abiotic: "diseases.types.abiotic",
}; };
export default function LargeDiseaseCard({ export default function LargeDiseaseCard({
disease, disease,
onPress, onPress,
index = 0, index = 0,
compact = false,
}: LargeDiseaseCardProps) { }: LargeDiseaseCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const type = TYPE_TINT[disease.type]; const type = TYPE_TINT[disease.type];
const severity = SEVERITY_TINT[disease.severity]; const severity = SEVERITY_TINT[disease.severity];
const cardRadius = compact ? 24 : 32;
const cardClassName = compact
? "bg-white p-5 min-h-[220px] justify-between"
: "bg-white p-6 min-h-[260px] justify-between";
const headerMb = compact ? "mb-3" : "mb-5";
const iconCircleClass = compact
? "w-9 h-9 bg-[#F8F9FA] rounded-full items-center justify-center"
: "w-11 h-11 bg-[#F8F9FA] rounded-full items-center justify-center";
const iconSize = compact ? 20 : 26;
const titleClass = compact
? "text-[18px] font-extrabold text-[#1A1A1A] leading-6 tracking-[-0.3px]"
: "text-[26px] font-extrabold text-[#1A1A1A] leading-8 tracking-[-0.6px]";
const descClass = compact
? "text-[13px] text-[#4A4A4A] leading-[18px]"
: "text-sm text-[#4A4A4A] leading-[21px]";
const descLines = compact ? 2 : 3;
const footerClass = compact
? "flex-row items-center justify-between mt-4 pt-3 border-t border-[#F5F5F5]"
: "flex-row items-center justify-between mt-6 pt-5 border-t border-[#F5F5F5]";
const badgeClass = compact
? "px-3 py-1 rounded-full"
: "px-3.5 py-1.5 rounded-full";
const badgeTextClass = compact
? "text-[10px] font-extrabold tracking-[1px]"
: "text-[11px] font-extrabold tracking-[1.2px]";
const severityClass = compact
? "flex-row items-center gap-1.5 px-3 py-1.5 rounded-2xl"
: "flex-row items-center gap-2 px-3.5 py-2 rounded-2xl";
const severityTextClass = compact
? "text-[10px] font-extrabold tracking-[0.8px]"
: "text-[11px] font-extrabold tracking-[1px]";
const arrowSize = compact ? "w-10 h-10" : "w-12 h-12";
const arrowIconSize = compact ? 18 : 20;
return ( return (
<Animated.View entering={FadeInDown.delay(index * 90).duration(550).springify().damping(16)}> <Animated.View
entering={FadeInDown.delay(index * 90)
.duration(550)
.springify()
.damping(16)}
>
<Pressable <Pressable
onPress={onPress} onPress={onPress}
className={cardClassName}
style={({ pressed }) => [ style={({ pressed }) => [
styles.card, styles.cardShadow,
{
borderRadius: cardRadius,
borderWidth: 1,
borderColor: "#EEEEEE",
},
pressed && { transform: [{ scale: 0.98 }] }, pressed && { transform: [{ scale: 0.98 }] },
]} ]}
> >
{/* Header: type badge + icon */} {/* Header: type badge + icon */}
<View style={styles.header}> <View className={`flex-row items-center justify-between ${headerMb}`}>
<View style={[styles.typeBadge, { backgroundColor: type.bg }]}> <View
<Text style={[styles.typeLabel, { color: type.fg }]}> className={badgeClass}
style={{ backgroundColor: type.bg }}
>
<Text
className={badgeTextClass}
style={{ color: type.fg }}
>
{t(TYPE_LABEL[disease.type]).toUpperCase()} {t(TYPE_LABEL[disease.type]).toUpperCase()}
</Text> </Text>
</View> </View>
<View style={styles.iconCircle}> <View className={iconCircleClass}>
<Ionicons <Ionicons
name={disease.icon as keyof typeof Ionicons.glyphMap} name={disease.icon as keyof typeof Ionicons.glyphMap}
size={26} size={iconSize}
color={disease.iconColor} color={disease.iconColor}
/> />
</View> </View>
</View> </View>
{/* Content */} {/* Content */}
<View style={styles.content}> <View className="gap-2">
<Text style={styles.title} numberOfLines={2}> <Text className={titleClass} numberOfLines={2}>
{t(disease.name)} {t(disease.name)}
</Text> </Text>
<Text style={styles.description} numberOfLines={3}> <Text className={descClass} numberOfLines={descLines}>
{t(disease.description)} {t(disease.description)}
</Text> </Text>
</View> </View>
{/* Footer: severity tag + arrow button */} {/* Footer: severity tag + arrow button */}
<View style={styles.footer}> <View className={footerClass}>
<View style={[styles.severityTag, { backgroundColor: severity.bg }]}> <View
<AlertTriangle size={14} color={severity.fg} strokeWidth={2.5} /> className={severityClass}
<Text style={[styles.severityLabel, { color: severity.fg }]}> style={{ backgroundColor: severity.bg }}
>
<AlertTriangle size={compact ? 12 : 14} color={severity.fg} strokeWidth={2.5} />
<Text
className={severityTextClass}
style={{ color: severity.fg }}
>
{t(severity.labelKey).toUpperCase()} {t(severity.labelKey).toUpperCase()}
</Text> </Text>
</View> </View>
<View style={styles.arrowButton}> <View
<ArrowRight size={20} color="#FFFFFF" strokeWidth={2.6} /> className={`${arrowSize} rounded-full items-center justify-center`}
style={[
styles.arrowShadow,
{ backgroundColor: colors.primary[800] },
]}
>
<ArrowRight size={arrowIconSize} color="#FFFFFF" strokeWidth={2.6} />
</View> </View>
</View> </View>
</Pressable> </Pressable>
@ -97,88 +165,14 @@ export default function LargeDiseaseCard({
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { cardShadow: {
backgroundColor: '#FFFFFF', shadowColor: "#000",
borderWidth: 1,
borderColor: '#EEEEEE',
borderRadius: 32,
padding: 24,
minHeight: 260,
justifyContent: 'space-between',
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 }, shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.08, shadowOpacity: 0.08,
shadowRadius: 30, shadowRadius: 30,
elevation: 8, elevation: 8,
}, },
header: { arrowShadow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
typeBadge: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 999,
},
typeLabel: {
fontSize: 11,
fontWeight: '800',
letterSpacing: 1.2,
},
iconCircle: {
width: 44,
height: 44,
borderRadius: 999,
backgroundColor: '#F8F9FA',
alignItems: 'center',
justifyContent: 'center',
},
content: {
gap: 10,
},
title: {
fontSize: 26,
fontWeight: '800',
color: '#1A1A1A',
lineHeight: 32,
letterSpacing: -0.6,
},
description: {
fontSize: 14,
color: '#4A4A4A',
lineHeight: 21,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 24,
paddingTop: 20,
borderTopWidth: 1,
borderTopColor: '#F5F5F5',
},
severityTag: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
},
severityLabel: {
fontSize: 11,
fontWeight: '800',
letterSpacing: 1,
},
arrowButton: {
width: 48,
height: 48,
borderRadius: 999,
backgroundColor: colors.primary[800],
alignItems: 'center',
justifyContent: 'center',
shadowColor: colors.primary[900], shadowColor: colors.primary[900],
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25, shadowOpacity: 0.25,

View file

@ -0,0 +1,54 @@
import { ScrollView, View } from "react-native";
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 type { Disease } from "@/data/diseases";
import type { RootStackParamList } from "@/types/navigation";
type Nav = NativeStackNavigationProp<RootStackParamList>;
interface Props {
diseases: Disease[];
isLoading?: boolean;
}
const CARD_WIDTH = 260;
export default function FrequentDiseasesHorizontal({
diseases,
isLoading,
}: Props) {
const navigation = useNavigation<Nav>();
if (isLoading && diseases.length === 0) {
return (
<View className="flex-row gap-4 px-5">
<CarouselCardSkeleton />
<CarouselCardSkeleton />
</View>
);
}
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 20, gap: 12, paddingVertical: 8 }}
>
{diseases.map((d, i) => (
<View key={d.id} style={{ width: CARD_WIDTH }}>
<LargeDiseaseCard
disease={d}
index={i}
compact={true}
onPress={() =>
navigation.navigate("DiseaseDetail", { diseaseId: d.id })
}
/>
</View>
))}
</ScrollView>
);
}

View file

@ -0,0 +1,67 @@
import { View, StyleSheet, Platform } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
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 { useHistory } from "@/hooks/useHistory";
import type { RootStackParamList } from "@/types/navigation";
type Nav = NativeStackNavigationProp<RootStackParamList>;
const MAX_RECENT = 3;
export default function RecentScans() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
const { history, toggleFavorite, deleteScan } = useHistory();
if (history.length === 0) {
return <HomeCta />;
}
const recent = history.slice(0, MAX_RECENT);
return (
<View className="mb-6 mx-5 gap-3">
<SectionHeader
title={t("home.recentScans")}
onViewAll={() => navigation.navigate("Main", { screen: "MyPlants" })}
/>
<View style={styles.card}>
{recent.map((scan, index) => (
<ScanListItem
key={scan.id}
scan={scan}
onPress={() => navigation.navigate("ScanDetail", { scanId: scan.id })}
onToggleFavorite={() => toggleFavorite(scan.id)}
onDelete={() => deleteScan(scan.id)}
grouped
showSeparator={index < recent.length - 1}
/>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: "#FFFFFF",
borderRadius: 16,
overflow: "hidden",
borderWidth: 1,
borderColor: "#F0F0F0",
...Platform.select({
ios: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.04,
shadowRadius: 8,
},
android: { elevation: 2 },
}),
},
});

View file

@ -10,10 +10,10 @@ import { useGuides } from "@/hooks/useGuides";
import SearchHeader from "@/components/home/SearchHeader"; import SearchHeader from "@/components/home/SearchHeader";
import SearchSection from "@/components/home/SearchSection"; import SearchSection from "@/components/home/SearchSection";
import SectionHeader from "@/components/home/components/homeheader"; import SectionHeader from "@/components/home/components/homeheader";
import FrequentDiseases from "@/components/home/FrequentDiseases"; import FrequentDiseasesHorizontal from "@/components/home/FrequentDiseasesHorizontal";
import SeasonAlert from "@/components/home/SeasonAlert"; // import SeasonAlert from "@/components/home/SeasonAlert"; // TODO: réactiver quand la page Notifications sera de retour
import PracticalGuides from "@/components/home/PracticalGuides"; import PracticalGuides from "@/components/home/PracticalGuides";
import HeroScanner from "@/components/home/HomeCta"; import RecentScans from "@/components/home/RecentScans";
type Nav = NativeStackNavigationProp<RootStackParamList>; type Nav = NativeStackNavigationProp<RootStackParamList>;
@ -34,7 +34,7 @@ export default function HomeScreen() {
<SearchSection /> <SearchSection />
<HeroScanner /> <RecentScans />
{/* Frequent diseases carousel */} {/* Frequent diseases carousel */}
<View className="mb-6 gap-3"> <View className="mb-6 gap-3">
@ -44,11 +44,11 @@ export default function HomeScreen() {
onViewAll={() => navigation.navigate("Main", { screen: "Guides" })} onViewAll={() => navigation.navigate("Main", { screen: "Guides" })}
/> />
</View> </View>
<FrequentDiseases diseases={diseases} isLoading={diseasesLoading} /> <FrequentDiseasesHorizontal diseases={diseases} isLoading={diseasesLoading} />
</View> </View>
{/* Season alert */} {/* 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"> <View className="mx-5 mb-6 gap-3">