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,
|
||||
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>
|
||||
<Text className="text-[13px] font-medium text-[#9E9E9E]">
|
||||
{t("map.plantCount", { count: scans.length })}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const STATUS_COLOR: Record<ScanStatus, string> = {
|
|||
healthy: colors.primary[800],
|
||||
infected: "#E63946",
|
||||
uncertain: "#F4A261",
|
||||
not_vine: "#9E9E9E",
|
||||
};
|
||||
|
||||
interface MapMarker {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue