Grapevine_Disease_Detection/VinEye/src/screens/GuideDetailScreen.tsx
Yanis 720dd34fdd add MyPlantsScreen + ScanDetailScreen + enriched admin + API mobile + project summary
Mobile:
- Replace LibraryScreen with MyPlantsScreen (date-grouped scan list, swipe actions, search, pull-to-refresh)
- Add ScanDetailScreen (immersive hero, confidence bar, cepage card, share/delete)
- Add DiseaseDetailScreen + GuideDetailScreen (hero pattern, animated entry)
- Add useScanDetail, useHistory (useCallback fix), dateGrouping utility
- Connect diseases/guides to admin API with cache + offline fallback
- Add NetworkContext, ToastContext, Skeleton loading components
- Extend ScanRecord type (isFavorite, location)
- Full i18n FR/EN for all new screens

Admin (vineye-admin):
- Enrich Disease/Guide Prisma schema (timeline, conditions, actions, sections)
- Enriched disease-form (7 sections) + guide-form (structured sections editor)
- Add mobile public API endpoints (diseases, guides by slug)
- Add Prisma migration + enriched seed data
- UI polish: sidebar, login, layout updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 03:19:39 +02:00

344 lines
8.8 KiB
TypeScript

import { useEffect } from "react";
import {
View,
ScrollView,
TouchableOpacity,
StyleSheet,
Platform,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import { useTranslation } from "react-i18next";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { Ionicons } from "@expo/vector-icons";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
Easing,
} from "react-native-reanimated";
import { Text } from "@/components/ui/text";
import Skeleton from "@/components/ui/Skeleton";
import { useGuideDetail } from "@/hooks/useGuideDetail";
import type { GuideSection } from "@/data/guides";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import type { RootStackParamList } from "@/types/navigation";
type Props = NativeStackScreenProps<RootStackParamList, "GuideDetail">;
const CATEGORY_LABELS: Record<string, { fr: string; en: string }> = {
beginner: { fr: "Débutant", en: "Beginner" },
treatment: { fr: "Traitement", en: "Treatment" },
varieties: { fr: "Cépages", en: "Varieties" },
seasonal: { fr: "Saisonnier", en: "Seasonal" },
};
export default function GuideDetailScreen({ route }: Props) {
const { guideId } = route.params;
const { t, i18n } = useTranslation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { guide, isLoading } = useGuideDetail(guideId);
// Entry animation
const contentY = useSharedValue(30);
const contentOpacity = useSharedValue(0);
useEffect(() => {
if (guide) {
const timing = { duration: 400, easing: Easing.bezier(0.25, 0.1, 0.25, 1) };
contentY.value = withTiming(0, timing);
contentOpacity.value = withTiming(1, timing);
}
}, [guide]);
const contentAnim = useAnimatedStyle(() => ({
transform: [{ translateY: contentY.value }],
opacity: contentOpacity.value,
}));
if (isLoading && !guide) {
return (
<View style={styles.centered}>
<Skeleton width={200} height={24} borderRadius={8} />
</View>
);
}
if (!guide) {
return (
<View style={styles.centered}>
<Text style={{ color: "#1A1A1A" }}>Guide introuvable</Text>
</View>
);
}
const lang = i18n.language === "en" ? "en" : "fr";
const catLabel = CATEGORY_LABELS[guide.category]?.[lang] ?? guide.category;
return (
<View style={styles.root}>
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + 40 }}
showsVerticalScrollIndicator={false}
>
{/* ── Hero Image ── */}
<View style={styles.heroContainer}>
{guide.image ? (
<Image
source={{ uri: guide.image }}
style={StyleSheet.absoluteFillObject}
contentFit="cover"
transition={300}
/>
) : (
<View style={[StyleSheet.absoluteFillObject, { backgroundColor: guide.bgColor, alignItems: "center", justifyContent: "center" }]}>
<Ionicons name={guide.icon as any} size={80} color={guide.iconColor} />
</View>
)}
<LinearGradient colors={["rgba(0,0,0,0.35)", "transparent"]} style={styles.gradientTop} />
<LinearGradient colors={["transparent", "#F8F9FB"]} style={styles.gradientBottom} />
{/* Back button */}
<TouchableOpacity
style={[styles.backBtn, { top: insets.top + 8 }]}
activeOpacity={0.8}
onPress={() => navigation.goBack()}
>
<Ionicons name="chevron-back" size={22} color="#1A1A1A" />
</TouchableOpacity>
{/* Read time badge */}
<View style={[styles.readTimeBadge, { top: insets.top + 8 }]}>
<Ionicons name="time-outline" size={14} color="#1A1A1A" />
<Text style={styles.readTimeText}>
{t("common.readTime", { min: guide.readTime })}
</Text>
</View>
</View>
{/* ── Content ── */}
<Animated.View style={contentAnim}>
{/* Title */}
<Text style={styles.title}>{t(guide.title)}</Text>
<Text style={styles.subtitle}>{t(guide.subtitle)}</Text>
{/* Category pill */}
<View style={styles.categoryRow}>
<View style={styles.categoryPill}>
<Text style={styles.categoryText}>{catLabel}</Text>
</View>
</View>
{/* Sections */}
{guide.content.map((section, index) => (
<View key={index}>
{/* Separator (except before first) */}
{index > 0 && <View style={styles.separator} />}
<SectionBlock section={section} t={t} />
</View>
))}
</Animated.View>
</ScrollView>
</View>
);
}
/* ── Section Block ── */
function SectionBlock({ section, t }: { section: GuideSection; t: (key: string) => string }) {
return (
<>
<Text style={styles.sectionTitle}>{t(section.title)}</Text>
<Text style={styles.sectionBody}>{t(section.body)}</Text>
{/* Section image */}
{section.image && (
<View style={styles.sectionImageWrapper}>
<Image
source={{ uri: section.image }}
style={StyleSheet.absoluteFillObject}
contentFit="cover"
transition={200}
/>
</View>
)}
{/* Tip card */}
{section.tip && (
<View style={styles.tipCard}>
<View style={styles.tipHeader}>
<Ionicons name="bulb-outline" size={18} color="#2D6A4F" />
<Text style={styles.tipLabel}>Conseil</Text>
</View>
<Text style={styles.tipText}>{t(section.tip)}</Text>
</View>
)}
</>
);
}
/* ── Styles ── */
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "#F8F9FB",
},
centered: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#F8F9FB",
},
// Hero
heroContainer: {
height: 280,
position: "relative",
backgroundColor: "#E0E0E0",
},
gradientTop: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 100,
},
gradientBottom: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 80,
},
backBtn: {
position: "absolute",
left: 16,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255, 255, 255, 0.9)",
alignItems: "center",
justifyContent: "center",
...Platform.select({
ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4 },
android: { elevation: 3 },
}),
},
readTimeBadge: {
position: "absolute",
right: 16,
flexDirection: "row",
alignItems: "center",
gap: 5,
backgroundColor: "rgba(255, 255, 255, 0.9)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
...Platform.select({
ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4 },
android: { elevation: 3 },
}),
},
readTimeText: {
fontSize: 13,
fontWeight: "600",
color: "#1A1A1A",
},
// Title
title: {
fontSize: 28,
fontWeight: "800",
color: "#1A1A1A",
paddingHorizontal: 20,
marginTop: -8,
},
subtitle: {
fontSize: 16,
fontWeight: "400",
color: "#8E8E93",
paddingHorizontal: 20,
marginTop: 4,
},
categoryRow: {
paddingHorizontal: 20,
marginTop: 12,
},
categoryPill: {
alignSelf: "flex-start",
backgroundColor: "rgba(45, 106, 79, 0.1)",
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 20,
},
categoryText: {
fontSize: 13,
fontWeight: "600",
color: "#2D6A4F",
},
// Separator
separator: {
height: 1,
backgroundColor: "#F0F0F0",
marginHorizontal: 20,
marginTop: 32,
},
// Sections
sectionTitle: {
fontSize: 22,
fontWeight: "700",
color: "#1A1A1A",
paddingHorizontal: 20,
marginTop: 32,
},
sectionBody: {
fontSize: 15,
lineHeight: 26,
color: "#444444",
paddingHorizontal: 20,
marginTop: 10,
},
sectionImageWrapper: {
height: 200,
borderRadius: 16,
overflow: "hidden",
marginTop: 16,
marginHorizontal: 20,
backgroundColor: "#E0E0E0",
},
// Tip
tipCard: {
backgroundColor: "rgba(45, 106, 79, 0.06)",
borderRadius: 16,
padding: 16,
marginTop: 16,
marginHorizontal: 20,
borderLeftWidth: 3,
borderLeftColor: "#2D6A4F",
},
tipHeader: {
flexDirection: "row",
alignItems: "center",
gap: 8,
marginBottom: 8,
},
tipLabel: {
fontSize: 14,
fontWeight: "700",
color: "#2D6A4F",
},
tipText: {
fontSize: 14,
lineHeight: 22,
color: "#2D6A4F",
},
});