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:
parent
e34e0db34c
commit
c0c85929ed
|
|
@ -23,9 +23,11 @@ import {
|
||||||
ScanLine,
|
ScanLine,
|
||||||
MapPin,
|
MapPin,
|
||||||
Check,
|
Check,
|
||||||
|
HelpCircle,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import Skeleton from "@/components/ui/Skeleton";
|
||||||
import { colors } from "@/theme/colors";
|
import { colors } from "@/theme/colors";
|
||||||
import { getScanStatus } from "@/types/detection";
|
import { getScanStatus } from "@/types/detection";
|
||||||
import { getScanDisplayName } from "@/utils/scanDisplay";
|
import { getScanDisplayName } from "@/utils/scanDisplay";
|
||||||
|
|
@ -33,6 +35,7 @@ import type { ScanRecord, ScanStatus } from "@/types/detection";
|
||||||
|
|
||||||
interface MapBottomSheetProps {
|
interface MapBottomSheetProps {
|
||||||
scans: ScanRecord[];
|
scans: ScanRecord[];
|
||||||
|
isLoading?: boolean;
|
||||||
previewScan?: ScanRecord | null;
|
previewScan?: ScanRecord | null;
|
||||||
onPreviewClose?: () => void;
|
onPreviewClose?: () => void;
|
||||||
onScanPress?: (scan: ScanRecord) => void;
|
onScanPress?: (scan: ScanRecord) => void;
|
||||||
|
|
@ -41,10 +44,28 @@ interface MapBottomSheetProps {
|
||||||
defaultIndex?: number;
|
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>(
|
export const MapBottomSheet = forwardRef<BottomSheet, MapBottomSheetProps>(
|
||||||
function MapBottomSheet(
|
function MapBottomSheet(
|
||||||
{
|
{
|
||||||
scans,
|
scans,
|
||||||
|
isLoading = false,
|
||||||
previewScan,
|
previewScan,
|
||||||
onPreviewClose,
|
onPreviewClose,
|
||||||
onScanPress,
|
onScanPress,
|
||||||
|
|
@ -245,12 +266,20 @@ export const MapBottomSheet = forwardRef<BottomSheet, MapBottomSheetProps>(
|
||||||
<Text className="text-lg font-bold text-[#1B1B1B]">
|
<Text className="text-lg font-bold text-[#1B1B1B]">
|
||||||
{t("map.scannedPlants")}
|
{t("map.scannedPlants")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-[13px] font-medium text-[#9E9E9E]">
|
{!isLoading && (
|
||||||
{t("map.plantCount", { count: scans.length })}
|
<Text className="text-[13px] font-medium text-[#9E9E9E]">
|
||||||
</Text>
|
{t("map.plantCount", { count: scans.length })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</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="items-center px-8 py-6 gap-2">
|
||||||
<View className="w-16 h-16 rounded-2xl bg-[#E9F5EC] items-center justify-center mb-2">
|
<View className="w-16 h-16 rounded-2xl bg-[#E9F5EC] items-center justify-center mb-2">
|
||||||
<MapPin
|
<MapPin
|
||||||
|
|
@ -307,6 +336,7 @@ const STATUS_TINT: Record<ScanStatus, { bg: string; fg: string }> = {
|
||||||
healthy: { bg: colors.primary[100], fg: colors.primary[800] },
|
healthy: { bg: colors.primary[100], fg: colors.primary[800] },
|
||||||
infected: { bg: "#FCEBEB", fg: "#A32D2D" },
|
infected: { bg: "#FCEBEB", fg: "#A32D2D" },
|
||||||
uncertain: { bg: "#FAEEDA", fg: "#BA7517" },
|
uncertain: { bg: "#FAEEDA", fg: "#BA7517" },
|
||||||
|
not_vine: { bg: "#EEEEEE", fg: "#5A5A5A" },
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ScanRowProps {
|
interface ScanRowProps {
|
||||||
|
|
@ -321,7 +351,13 @@ function ScanRow({ scan, isLast, onPress, onEdit }: ScanRowProps) {
|
||||||
const status = getScanStatus(scan);
|
const status = getScanStatus(scan);
|
||||||
const tint = STATUS_TINT[status];
|
const tint = STATUS_TINT[status];
|
||||||
const Icon =
|
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 displayName = getScanDisplayName(scan, t);
|
||||||
const formattedDate = new Date(scan.createdAt).toLocaleDateString("fr-FR", {
|
const formattedDate = new Date(scan.createdAt).toLocaleDateString("fr-FR", {
|
||||||
|
|
@ -374,3 +410,4 @@ function ScanRow({ scan, isLast, onPress, onEdit }: ScanRowProps) {
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const STATUS_COLOR: Record<ScanStatus, string> = {
|
||||||
healthy: colors.primary[800],
|
healthy: colors.primary[800],
|
||||||
infected: "#E63946",
|
infected: "#E63946",
|
||||||
uncertain: "#F4A261",
|
uncertain: "#F4A261",
|
||||||
|
not_vine: "#9E9E9E",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MapMarker {
|
interface MapMarker {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ const STATUS_FILL: Record<ScanStatus, string> = {
|
||||||
healthy: colors.primary[700],
|
healthy: colors.primary[700],
|
||||||
infected: '#E63946',
|
infected: '#E63946',
|
||||||
uncertain: '#F4A261',
|
uncertain: '#F4A261',
|
||||||
|
not_vine: '#9E9E9E',
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ const STATUS_STYLE: Record<ScanStatus, { bg: string; fg: string; dot: string; la
|
||||||
dot: '#F4A261',
|
dot: '#F4A261',
|
||||||
labelKey: 'myPlants.status.uncertain',
|
labelKey: 'myPlants.status.uncertain',
|
||||||
},
|
},
|
||||||
|
not_vine: {
|
||||||
|
bg: '#EEEEEE',
|
||||||
|
fg: '#5A5A5A',
|
||||||
|
dot: '#9E9E9E',
|
||||||
|
labelKey: 'myPlants.status.notVine',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatusTag({ status }: StatusTagProps) {
|
export function StatusTag({ status }: StatusTagProps) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,14 @@
|
||||||
"spreadMethod": "Spread method",
|
"spreadMethod": "Spread method",
|
||||||
"timeline": "Active period"
|
"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": {
|
"search": {
|
||||||
"placeholder": "What are you looking for?",
|
"placeholder": "What are you looking for?",
|
||||||
"placeholderMap": "Search one of my plants...",
|
"placeholderMap": "Search one of my plants...",
|
||||||
|
|
@ -310,7 +318,8 @@
|
||||||
"status": {
|
"status": {
|
||||||
"healthy": "Healthy",
|
"healthy": "Healthy",
|
||||||
"infected": "Diseased",
|
"infected": "Diseased",
|
||||||
"uncertain": "Uncertain"
|
"uncertain": "Uncertain",
|
||||||
|
"notVine": "Not a vine"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "No plants scanned yet",
|
"title": "No plants scanned yet",
|
||||||
|
|
@ -447,6 +456,8 @@
|
||||||
"result": {
|
"result": {
|
||||||
"vineDetected": "Vine detected!",
|
"vineDetected": "Vine detected!",
|
||||||
"notVine": "This is not a vine",
|
"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",
|
"uncertain": "Uncertain result",
|
||||||
"uncertainTitle": "Uncertain analysis",
|
"uncertainTitle": "Uncertain analysis",
|
||||||
"uncertainMessage": "The model is not confident enough. Take a sharper, well-lit photo centered on a leaf.",
|
"uncertainMessage": "The model is not confident enough. Take a sharper, well-lit photo centered on a leaf.",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,14 @@
|
||||||
"spreadMethod": "Propagation",
|
"spreadMethod": "Propagation",
|
||||||
"timeline": "Période d'activité"
|
"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": {
|
"search": {
|
||||||
"placeholder": "Que cherchez-vous ?",
|
"placeholder": "Que cherchez-vous ?",
|
||||||
"placeholderMap": "Rechercher une de mes plantes...",
|
"placeholderMap": "Rechercher une de mes plantes...",
|
||||||
|
|
@ -310,7 +318,8 @@
|
||||||
"status": {
|
"status": {
|
||||||
"healthy": "Saine",
|
"healthy": "Saine",
|
||||||
"infected": "Malade",
|
"infected": "Malade",
|
||||||
"uncertain": "Incertain"
|
"uncertain": "Incertain",
|
||||||
|
"notVine": "Non vigne"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Aucune plante scannée",
|
"title": "Aucune plante scannée",
|
||||||
|
|
@ -447,6 +456,8 @@
|
||||||
"result": {
|
"result": {
|
||||||
"vineDetected": "Vigne détectée !",
|
"vineDetected": "Vigne détectée !",
|
||||||
"notVine": "Ce n'est pas une vigne",
|
"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",
|
"uncertain": "Résultat incertain",
|
||||||
"uncertainTitle": "Analyse incertaine",
|
"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.",
|
"uncertainMessage": "Le modèle n'est pas suffisamment confiant. Prenez une photo plus nette, mieux éclairée, et centrée sur une feuille.",
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ export default function MapScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const route = useRoute<MapRoute>();
|
const route = useRoute<MapRoute>();
|
||||||
const { history, renameScan, reload } = useHistory();
|
const { history, isLoading: historyLoading, renameScan, reload } = useHistory();
|
||||||
const { requestAndGetLocation } = useScanLocation();
|
const { requestAndGetLocation } = useScanLocation();
|
||||||
const mapRef = useRef<VineyardMapHandle>(null);
|
const mapRef = useRef<VineyardMapHandle>(null);
|
||||||
const sheetRef = useRef<BottomSheet>(null);
|
const sheetRef = useRef<BottomSheet>(null);
|
||||||
|
|
@ -281,6 +281,7 @@ export default function MapScreen() {
|
||||||
<MapBottomSheet
|
<MapBottomSheet
|
||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
scans={locatedScans}
|
scans={locatedScans}
|
||||||
|
isLoading={historyLoading}
|
||||||
previewScan={previewScan}
|
previewScan={previewScan}
|
||||||
onPreviewClose={handlePreviewClose}
|
onPreviewClose={handlePreviewClose}
|
||||||
onScanPress={handleScanPress}
|
onScanPress={handleScanPress}
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,68 @@ export default function ResultScreen() {
|
||||||
navigation.navigate('DiseaseDetail', { diseaseId: diseaseSlug.replace(/-/g, '_') });
|
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 (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-[#FAFAFA]">
|
<SafeAreaView className="flex-1 bg-[#FAFAFA]">
|
||||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerClassName="gap-5 p-4 pb-12">
|
<ScrollView showsVerticalScrollIndicator={false} contentContainerClassName="gap-5 p-4 pb-12">
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,10 @@ export interface ScanRecord {
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScanStatus = 'healthy' | 'infected' | 'uncertain';
|
export type ScanStatus = 'healthy' | 'infected' | 'uncertain' | 'not_vine';
|
||||||
|
|
||||||
export function getScanStatus(scan: ScanRecord): ScanStatus {
|
export function getScanStatus(scan: ScanRecord): ScanStatus {
|
||||||
|
if (scan.detection.result === 'not_vine') return 'not_vine';
|
||||||
const cls = scan.detection.diseaseClass;
|
const cls = scan.detection.diseaseClass;
|
||||||
if (cls === 'healthy') return 'healthy';
|
if (cls === 'healthy') return 'healthy';
|
||||||
if (cls === 'black_rot' || cls === 'esca' || cls === 'leaf_blight') return 'infected';
|
if (cls === 'black_rot' || cls === 'esca' || cls === 'leaf_blight') return 'infected';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue