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 { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import { ArrowRight, AlertTriangle } from 'lucide-react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { View, Pressable, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { ArrowRight, AlertTriangle } from "lucide-react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import { Text } from '@/components/ui/text';
import { colors } from '@/theme/colors';
import type { Disease } from '@/data/diseases';
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
import type { Disease } from "@/data/diseases";
interface LargeDiseaseCardProps {
disease: Disease;
onPress: () => void;
index?: number;
compact?: boolean;
}
const TYPE_TINT: Record<Disease['type'], { bg: string; fg: string }> = {
fungal: { bg: '#7B1FA2', fg: '#FFFFFF' },
bacterial: { bg: '#C62828', fg: '#FFFFFF' },
pest: { bg: '#EF6C00', fg: '#FFFFFF' },
abiotic: { bg: '#1565C0', fg: '#FFFFFF' },
const TYPE_TINT: Record<Disease["type"], { bg: string; fg: string }> = {
fungal: { bg: "#7B1FA2", fg: "#FFFFFF" },
bacterial: { bg: "#C62828", fg: "#FFFFFF" },
pest: { bg: "#EF6C00", fg: "#FFFFFF" },
abiotic: { bg: "#1565C0", fg: "#FFFFFF" },
};
const SEVERITY_TINT: Record<Disease['severity'], { bg: string; fg: string; labelKey: string }> = {
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 SEVERITY_TINT: Record<
Disease["severity"],
{ bg: string; fg: string; labelKey: string }
> = {
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> = {
fungal: 'diseases.types.fungal',
bacterial: 'diseases.types.bacterial',
pest: 'diseases.types.pest',
abiotic: 'diseases.types.abiotic',
const TYPE_LABEL: Record<Disease["type"], string> = {
fungal: "diseases.types.fungal",
bacterial: "diseases.types.bacterial",
pest: "diseases.types.pest",
abiotic: "diseases.types.abiotic",
};
export default function LargeDiseaseCard({
disease,
onPress,
index = 0,
compact = false,
}: LargeDiseaseCardProps) {
const { t } = useTranslation();
const type = TYPE_TINT[disease.type];
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 (
<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
onPress={onPress}
className={cardClassName}
style={({ pressed }) => [
styles.card,
styles.cardShadow,
{
borderRadius: cardRadius,
borderWidth: 1,
borderColor: "#EEEEEE",
},
pressed && { transform: [{ scale: 0.98 }] },
]}
>
{/* Header: type badge + icon */}
<View style={styles.header}>
<View style={[styles.typeBadge, { backgroundColor: type.bg }]}>
<Text style={[styles.typeLabel, { color: type.fg }]}>
<View className={`flex-row items-center justify-between ${headerMb}`}>
<View
className={badgeClass}
style={{ backgroundColor: type.bg }}
>
<Text
className={badgeTextClass}
style={{ color: type.fg }}
>
{t(TYPE_LABEL[disease.type]).toUpperCase()}
</Text>
</View>
<View style={styles.iconCircle}>
<View className={iconCircleClass}>
<Ionicons
name={disease.icon as keyof typeof Ionicons.glyphMap}
size={26}
size={iconSize}
color={disease.iconColor}
/>
</View>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.title} numberOfLines={2}>
<View className="gap-2">
<Text className={titleClass} numberOfLines={2}>
{t(disease.name)}
</Text>
<Text style={styles.description} numberOfLines={3}>
<Text className={descClass} numberOfLines={descLines}>
{t(disease.description)}
</Text>
</View>
{/* Footer: severity tag + arrow button */}
<View style={styles.footer}>
<View style={[styles.severityTag, { backgroundColor: severity.bg }]}>
<AlertTriangle size={14} color={severity.fg} strokeWidth={2.5} />
<Text style={[styles.severityLabel, { color: severity.fg }]}>
<View className={footerClass}>
<View
className={severityClass}
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()}
</Text>
</View>
<View style={styles.arrowButton}>
<ArrowRight size={20} color="#FFFFFF" strokeWidth={2.6} />
<View
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>
</Pressable>
@ -97,88 +165,14 @@ export default function LargeDiseaseCard({
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: '#EEEEEE',
borderRadius: 32,
padding: 24,
minHeight: 260,
justifyContent: 'space-between',
shadowColor: '#000',
cardShadow: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.08,
shadowRadius: 30,
elevation: 8,
},
header: {
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',
arrowShadow: {
shadowColor: colors.primary[900],
shadowOffset: { width: 0, height: 4 },
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 SearchSection from "@/components/home/SearchSection";
import SectionHeader from "@/components/home/components/homeheader";
import FrequentDiseases from "@/components/home/FrequentDiseases";
import SeasonAlert from "@/components/home/SeasonAlert";
import FrequentDiseasesHorizontal from "@/components/home/FrequentDiseasesHorizontal";
// import SeasonAlert from "@/components/home/SeasonAlert"; // TODO: réactiver quand la page Notifications sera de retour
import PracticalGuides from "@/components/home/PracticalGuides";
import HeroScanner from "@/components/home/HomeCta";
import RecentScans from "@/components/home/RecentScans";
type Nav = NativeStackNavigationProp<RootStackParamList>;
@ -34,7 +34,7 @@ export default function HomeScreen() {
<SearchSection />
<HeroScanner />
<RecentScans />
{/* Frequent diseases carousel */}
<View className="mb-6 gap-3">
@ -44,11 +44,11 @@ export default function HomeScreen() {
onViewAll={() => navigation.navigate("Main", { screen: "Guides" })}
/>
</View>
<FrequentDiseases diseases={diseases} isLoading={diseasesLoading} />
<FrequentDiseasesHorizontal diseases={diseases} isLoading={diseasesLoading} />
</View>
{/* Season alert */}
<SeasonAlert />
{/* 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">