feat(scan,result): not_vine status + dedicated UI variants

The detection pipeline already returned result === 'not_vine' but the
app rendered it the same as a low-confidence positive, which was
confusing (a coffee cup classified at 35% would show up as "uncertain
vine"). Surface non-vine results explicitly across the app:

- New ScanStatus 'not_vine' branch in types/detection.getScanStatus()
- StatusTag, ScanListItem fill, MapBottomSheet row icon (HelpCircle)
  and MapView marker color get a neutral grey palette for not_vine
- ResultScreen short-circuits to a centered "Aucune vigne détectée"
  layout with a single CTA "Reprendre une photo" (instead of pretending
  the model has a meaningful prediction to show)
- MapBottomSheet learns an isLoading prop and renders 4 row skeletons
  while useHistory rehydrates, instead of flashing the "no plants" empty
  state. MapScreen plumbs historyLoading through

Bundles the i18n additions (FR + EN) for this commit and the next
two: result.notVineTitle/Message, myPlants.status.notVine, plus the
network.* and scanner.galleryComingSoon* keys used by follow-up
commits — splitting JSON hunks would have been more churn than
signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yanis 2026-05-01 14:02:42 +02:00
parent e34e0db34c
commit c0c85929ed
9 changed files with 140 additions and 9 deletions

View file

@ -23,9 +23,11 @@ import {
ScanLine,
MapPin,
Check,
HelpCircle,
} from "lucide-react-native";
import { Text } from "@/components/ui/text";
import Skeleton from "@/components/ui/Skeleton";
import { colors } from "@/theme/colors";
import { getScanStatus } from "@/types/detection";
import { getScanDisplayName } from "@/utils/scanDisplay";
@ -33,6 +35,7 @@ import type { ScanRecord, ScanStatus } from "@/types/detection";
interface MapBottomSheetProps {
scans: ScanRecord[];
isLoading?: boolean;
previewScan?: ScanRecord | null;
onPreviewClose?: () => void;
onScanPress?: (scan: ScanRecord) => void;
@ -41,10 +44,28 @@ interface MapBottomSheetProps {
defaultIndex?: number;
}
function ScanRowSkeleton({ isLast }: { isLast: boolean }) {
return (
<View
className={`flex-row items-center gap-3.5 py-3.5 ${
!isLast ? "border-b border-[#F5F5F5]" : ""
}`}
>
<Skeleton width={48} height={48} borderRadius={14} />
<View className="flex-1 gap-2">
<Skeleton width="70%" height={14} borderRadius={6} />
<Skeleton width="45%" height={11} borderRadius={5} />
</View>
<Skeleton width={32} height={32} borderRadius={999} />
</View>
);
}
export const MapBottomSheet = forwardRef<BottomSheet, MapBottomSheetProps>(
function MapBottomSheet(
{
scans,
isLoading = false,
previewScan,
onPreviewClose,
onScanPress,
@ -245,12 +266,20 @@ export const MapBottomSheet = forwardRef<BottomSheet, MapBottomSheetProps>(
<Text className="text-lg font-bold text-[#1B1B1B]">
{t("map.scannedPlants")}
</Text>
{!isLoading && (
<Text className="text-[13px] font-medium text-[#9E9E9E]">
{t("map.plantCount", { count: scans.length })}
</Text>
)}
</View>
{scans.length === 0 ? (
{isLoading && scans.length === 0 ? (
<View className="px-5">
{[0, 1, 2, 3].map((i) => (
<ScanRowSkeleton key={i} isLast={i === 3} />
))}
</View>
) : scans.length === 0 ? (
<View className="items-center px-8 py-6 gap-2">
<View className="w-16 h-16 rounded-2xl bg-[#E9F5EC] items-center justify-center mb-2">
<MapPin
@ -307,6 +336,7 @@ const STATUS_TINT: Record<ScanStatus, { bg: string; fg: string }> = {
healthy: { bg: colors.primary[100], fg: colors.primary[800] },
infected: { bg: "#FCEBEB", fg: "#A32D2D" },
uncertain: { bg: "#FAEEDA", fg: "#BA7517" },
not_vine: { bg: "#EEEEEE", fg: "#5A5A5A" },
};
interface ScanRowProps {
@ -321,7 +351,13 @@ function ScanRow({ scan, isLast, onPress, onEdit }: ScanRowProps) {
const status = getScanStatus(scan);
const tint = STATUS_TINT[status];
const Icon =
status === "healthy" ? Leaf : status === "infected" ? AlertTriangle : Clock;
status === "healthy"
? Leaf
: status === "infected"
? AlertTriangle
: status === "not_vine"
? HelpCircle
: Clock;
const displayName = getScanDisplayName(scan, t);
const formattedDate = new Date(scan.createdAt).toLocaleDateString("fr-FR", {
@ -374,3 +410,4 @@ function ScanRow({ scan, isLast, onPress, onEdit }: ScanRowProps) {
</View>
);
}

View file

@ -34,6 +34,7 @@ const STATUS_COLOR: Record<ScanStatus, string> = {
healthy: colors.primary[800],
infected: "#E63946",
uncertain: "#F4A261",
not_vine: "#9E9E9E",
};
interface MapMarker {

View file

@ -28,6 +28,7 @@ const STATUS_FILL: Record<ScanStatus, string> = {
healthy: colors.primary[700],
infected: '#E63946',
uncertain: '#F4A261',
not_vine: '#9E9E9E',
};
// eslint-disable-next-line @typescript-eslint/no-var-requires

View file

@ -27,6 +27,12 @@ const STATUS_STYLE: Record<ScanStatus, { bg: string; fg: string; dot: string; la
dot: '#F4A261',
labelKey: 'myPlants.status.uncertain',
},
not_vine: {
bg: '#EEEEEE',
fg: '#5A5A5A',
dot: '#9E9E9E',
labelKey: 'myPlants.status.notVine',
},
};
export function StatusTag({ status }: StatusTagProps) {

View file

@ -22,6 +22,14 @@
"spreadMethod": "Spread method",
"timeline": "Active period"
},
"network": {
"offlineToastTitle": "Offline mode",
"offlineToastDescription": "Using cached data",
"onlineToastTitle": "Back online",
"homeOfflineModalTitle": "No connection",
"homeOfflineModalMessage": "You can keep using the app with local data. For up-to-date info (new diseases, guides…), reconnect to the Internet.",
"homeOfflineModalAction": "Got it"
},
"search": {
"placeholder": "What are you looking for?",
"placeholderMap": "Search one of my plants...",
@ -310,7 +318,8 @@
"status": {
"healthy": "Healthy",
"infected": "Diseased",
"uncertain": "Uncertain"
"uncertain": "Uncertain",
"notVine": "Not a vine"
},
"empty": {
"title": "No plants scanned yet",
@ -447,6 +456,8 @@
"result": {
"vineDetected": "Vine detected!",
"notVine": "This is not a vine",
"notVineTitle": "No grapevine detected",
"notVineMessage": "The image doesn't seem to show a grapevine. Try again with a clear, sharp, centered leaf.",
"uncertain": "Uncertain result",
"uncertainTitle": "Uncertain analysis",
"uncertainMessage": "The model is not confident enough. Take a sharper, well-lit photo centered on a leaf.",

View file

@ -22,6 +22,14 @@
"spreadMethod": "Propagation",
"timeline": "Période d'activité"
},
"network": {
"offlineToastTitle": "Mode hors-ligne",
"offlineToastDescription": "Données en cache utilisées",
"onlineToastTitle": "Connexion rétablie",
"homeOfflineModalTitle": "Aucune connexion",
"homeOfflineModalMessage": "Tu peux continuer à utiliser l'app avec les données locales. Pour des informations à jour (nouvelles maladies, guides…), reconnecte-toi à Internet.",
"homeOfflineModalAction": "Compris"
},
"search": {
"placeholder": "Que cherchez-vous ?",
"placeholderMap": "Rechercher une de mes plantes...",
@ -310,7 +318,8 @@
"status": {
"healthy": "Saine",
"infected": "Malade",
"uncertain": "Incertain"
"uncertain": "Incertain",
"notVine": "Non vigne"
},
"empty": {
"title": "Aucune plante scannée",
@ -447,6 +456,8 @@
"result": {
"vineDetected": "Vigne détectée !",
"notVine": "Ce n'est pas une vigne",
"notVineTitle": "Aucune vigne détectée",
"notVineMessage": "L'image ne semble pas montrer une vigne. Réessayez avec une feuille bien visible, nette et centrée.",
"uncertain": "Résultat incertain",
"uncertainTitle": "Analyse incertaine",
"uncertainMessage": "Le modèle n'est pas suffisamment confiant. Prenez une photo plus nette, mieux éclairée, et centrée sur une feuille.",

View file

@ -75,7 +75,7 @@ export default function MapScreen() {
const insets = useSafeAreaInsets();
const navigation = useNavigation<Nav>();
const route = useRoute<MapRoute>();
const { history, renameScan, reload } = useHistory();
const { history, isLoading: historyLoading, renameScan, reload } = useHistory();
const { requestAndGetLocation } = useScanLocation();
const mapRef = useRef<VineyardMapHandle>(null);
const sheetRef = useRef<BottomSheet>(null);
@ -281,6 +281,7 @@ export default function MapScreen() {
<MapBottomSheet
ref={sheetRef}
scans={locatedScans}
isLoading={historyLoading}
previewScan={previewScan}
onPreviewClose={handlePreviewClose}
onScanPress={handleScanPress}

View file

@ -91,6 +91,68 @@ export default function ResultScreen() {
navigation.navigate('DiseaseDetail', { diseaseId: diseaseSlug.replace(/-/g, '_') });
}
// Cas "pas une vigne" → layout centré verticalement avec message explicatif
if (detection.result === 'not_vine') {
return (
<SafeAreaView className="flex-1 bg-[#FAFAFA]">
<View className="flex-1 p-4">
<TouchableOpacity
className="h-8 w-8 items-center justify-center self-end rounded-full bg-neutral-200"
onPress={() => navigation.goBack()}
>
<Ionicons name="close" size={20} color={colors.neutral[700]} />
</TouchableOpacity>
<View className="flex-1 items-center justify-center gap-6">
<Animated.View className="items-center gap-3" style={headerStyle}>
<ProgressCircle
size={120}
strokeWidth={10}
progress={detection.confidence / 100}
color={statusColor}
trackColor={statusColor + '25'}
>
<Text className="text-[22px] font-extrabold" style={{ color: statusColor }}>
{detection.confidence}%
</Text>
</ProgressCircle>
<View className="flex-row items-center gap-1.5">
<Ionicons name={statusIcon} size={20} color={statusColor} />
<Text className="text-[13px] font-medium" style={{ color: statusColor }}>
{statusLabel}
</Text>
</View>
</Animated.View>
<Animated.View
style={cardStyle}
className="w-full max-w-[360px] gap-2 rounded-[20px] bg-white p-6 shadow-sm"
>
<Text className="text-center text-[20px] font-bold text-neutral-900">
{t('result.notVineTitle')}
</Text>
<Text className="text-center text-[13px] leading-[22px] text-neutral-600">
{t('result.notVineMessage')}
</Text>
</Animated.View>
</View>
<Animated.View style={cardStyle} className="pb-2">
<Button
variant="default"
size="lg"
className="w-full rounded-[14px]"
onPress={() => navigation.goBack()}
>
<Ionicons name="scan" size={18} color={colors.surface} />
<Text className="text-white">{t('result.scanAgain')}</Text>
</Button>
</Animated.View>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView className="flex-1 bg-[#FAFAFA]">
<ScrollView showsVerticalScrollIndicator={false} contentContainerClassName="gap-5 p-4 pb-12">

View file

@ -35,9 +35,10 @@ export interface ScanRecord {
} | null;
}
export type ScanStatus = 'healthy' | 'infected' | 'uncertain';
export type ScanStatus = 'healthy' | 'infected' | 'uncertain' | 'not_vine';
export function getScanStatus(scan: ScanRecord): ScanStatus {
if (scan.detection.result === 'not_vine') return 'not_vine';
const cls = scan.detection.diseaseClass;
if (cls === 'healthy') return 'healthy';
if (cls === 'black_rot' || cls === 'esca' || cls === 'leaf_blight') return 'infected';