feat(map): rebuild Map screen on WebView+Leaflet with scan markers
Replace the Google-Maps-backed react-native-maps screen with a self-
contained WebView running Leaflet + Carto/OSM tiles. No API key, no
native compilation surface. The map is now driven by the real scan
history (useHistory) instead of mock parcels.
What's on the screen now:
- Markers for every ScanRecord that carries lat/lng, colored by
status (healthy / infected / uncertain) derived from diseaseClass.
- Tapping a marker animates the camera and opens ScanDetail.
- Bottom sheet lists the same located scans with rename support: a
pencil opens a modal-input that calls renameScan() to set
ScanRecord.customName (empty value clears it). When the history is
empty the sheet auto-snaps higher and shows a CTA to the Scanner.
- Region chips (Bordeaux/Bourgogne/Champagne) animate the camera and
draw the actual department polygon as a dashed green outline. The
GeoJSON is fetched on the React Native side (avoids the opaque
origin CORS issue inside `source={{ html }}`) and cached in a
useRef Map.
- "Ma position" filter + Locate FAB drop a circular green pin with a
smiley SVG and a pulse halo at the user's GPS coords.
- FloatingActions and FloatingSearch tags restyled to match the
Apple-inspired Bento spec (rounded-full FABs, 56x56, soft shadows,
primary[900] active state).
VineyardMarker (orphan since markers are SVG inside the WebView) and
the data/mockScans.ts file were removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3be2d3b531
commit
d30f4f250c
98
VinEye/src/components/map/FloatingActions.tsx
Normal file
98
VinEye/src/components/map/FloatingActions.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { View, Pressable, StyleSheet } from "react-native";
|
||||
import { Layers, LocateFixed, Satellite } from "lucide-react-native";
|
||||
|
||||
import { colors } from "@/theme/colors";
|
||||
|
||||
export type FloatingActionId = "layers" | "locate" | "satellite";
|
||||
|
||||
interface FloatingActionsProps {
|
||||
onLayers?: () => void;
|
||||
onLocate?: () => void;
|
||||
onSatellite?: () => void;
|
||||
activeAction?: FloatingActionId;
|
||||
}
|
||||
|
||||
export function FloatingActions({
|
||||
onLayers,
|
||||
onLocate,
|
||||
onSatellite,
|
||||
activeAction,
|
||||
}: FloatingActionsProps) {
|
||||
return (
|
||||
<View style={styles.column}>
|
||||
<ActionButton active={activeAction === "layers"} onPress={onLayers}>
|
||||
<Layers
|
||||
size={22}
|
||||
color={activeAction === "layers" ? "#FFFFFF" : colors.primary[800]}
|
||||
strokeWidth={2.2}
|
||||
/>
|
||||
</ActionButton>
|
||||
<ActionButton active={activeAction === "locate"} onPress={onLocate}>
|
||||
<LocateFixed
|
||||
size={22}
|
||||
color={activeAction === "locate" ? "#FFFFFF" : colors.primary[800]}
|
||||
strokeWidth={2.2}
|
||||
/>
|
||||
</ActionButton>
|
||||
<ActionButton active={activeAction === "satellite"} onPress={onSatellite}>
|
||||
<Satellite
|
||||
size={22}
|
||||
color={activeAction === "satellite" ? "#FFFFFF" : colors.primary[800]}
|
||||
strokeWidth={2.2}
|
||||
/>
|
||||
</ActionButton>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
children: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
function ActionButton({ children, onPress, active }: ActionButtonProps) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
active ? styles.buttonActive : styles.buttonInactive,
|
||||
pressed && { transform: [{ scale: 0.95 }] },
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
column: {
|
||||
gap: 12,
|
||||
},
|
||||
button: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 999,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
buttonInactive: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#F0F0F0",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 10,
|
||||
elevation: 4,
|
||||
},
|
||||
buttonActive: {
|
||||
backgroundColor: colors.primary[900],
|
||||
shadowColor: colors.primary[900],
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
});
|
||||
160
VinEye/src/components/map/FloatingSearch.tsx
Normal file
160
VinEye/src/components/map/FloatingSearch.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Image,
|
||||
} from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, MapPin } from "lucide-react-native";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { colors } from "@/theme/colors";
|
||||
import { WINE_REGIONS } from "@/data/wineRegions";
|
||||
|
||||
export type MapFilterId = "myLocation" | string;
|
||||
|
||||
interface FloatingSearchProps {
|
||||
activeFilter: MapFilterId | null;
|
||||
onFilterPress?: (id: MapFilterId) => void;
|
||||
}
|
||||
|
||||
export function FloatingSearch({ activeFilter, onFilterPress }: FloatingSearchProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filters = [
|
||||
{ id: "myLocation", labelKey: "map.filters.myLocation", icon: "location" as const },
|
||||
...WINE_REGIONS.map((r) => ({ id: r.id, labelKey: r.labelKey, icon: undefined })),
|
||||
];
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={20} color={colors.primary[800]} strokeWidth={2} />
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={t("map.searchPlaceholder")}
|
||||
placeholderTextColor={colors.neutral[500]}
|
||||
style={styles.input}
|
||||
/>
|
||||
<View style={styles.logoWrap}>
|
||||
<Image
|
||||
source={require("../../../assets/logo.png")}
|
||||
style={styles.logo}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.chipsRow}
|
||||
>
|
||||
{filters.map((filter) => {
|
||||
const isActive = activeFilter === filter.id;
|
||||
return (
|
||||
<Pressable
|
||||
key={filter.id}
|
||||
onPress={() => onFilterPress?.(filter.id)}
|
||||
style={[styles.chip, isActive && styles.chipActive]}
|
||||
>
|
||||
{filter.icon === "location" && (
|
||||
<MapPin
|
||||
size={14}
|
||||
color={isActive ? "#FFFFFF" : colors.neutral[800]}
|
||||
strokeWidth={2.2}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.chipText,
|
||||
isActive && styles.chipTextActive,
|
||||
]}
|
||||
>
|
||||
{t(filter.labelKey)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
searchBar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
gap: 12,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.neutral[200],
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: colors.neutral[900],
|
||||
padding: 0,
|
||||
},
|
||||
logoWrap: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
borderWidth: 2,
|
||||
borderColor: colors.primary[200],
|
||||
},
|
||||
logo: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
chipsRow: {
|
||||
gap: 8,
|
||||
paddingTop: 12,
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
chip: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
backgroundColor: "#FFFFFF",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: "#F0F0F0",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 6,
|
||||
elevation: 2,
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: colors.primary[800],
|
||||
borderColor: colors.primary[800],
|
||||
shadowOpacity: 0.12,
|
||||
elevation: 4,
|
||||
},
|
||||
chipText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
color: "#2D2D2D",
|
||||
},
|
||||
chipTextActive: {
|
||||
color: "#FFFFFF",
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
433
VinEye/src/components/map/MapBottomSheet.tsx
Normal file
433
VinEye/src/components/map/MapBottomSheet.tsx
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
import { forwardRef, useMemo, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import BottomSheet, { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronRight,
|
||||
AlertTriangle,
|
||||
Leaf,
|
||||
Clock,
|
||||
Pencil,
|
||||
X,
|
||||
ScanLine,
|
||||
MapPin,
|
||||
} from "lucide-react-native";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { colors } from "@/theme/colors";
|
||||
import { getScanStatus } from "@/types/detection";
|
||||
import { getScanDisplayName } from "@/utils/scanDisplay";
|
||||
import type { ScanRecord, ScanStatus } from "@/types/detection";
|
||||
|
||||
interface MapBottomSheetProps {
|
||||
scans: ScanRecord[];
|
||||
onScanPress?: (scan: ScanRecord) => void;
|
||||
onRename?: (scanId: string, newName: string) => void;
|
||||
onScanCta?: () => void;
|
||||
defaultIndex?: number;
|
||||
}
|
||||
|
||||
export const MapBottomSheet = forwardRef<BottomSheet, MapBottomSheetProps>(
|
||||
function MapBottomSheet({ scans, onScanPress, onRename, onScanCta, defaultIndex = 0 }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const snapPoints = useMemo(() => ["20%", "55%", "85%"], []);
|
||||
const [renamingScan, setRenamingScan] = useState<ScanRecord | null>(null);
|
||||
const [draftName, setDraftName] = useState("");
|
||||
|
||||
function handleStartRename(scan: ScanRecord) {
|
||||
setRenamingScan(scan);
|
||||
setDraftName(getScanDisplayName(scan, t));
|
||||
}
|
||||
|
||||
function handleConfirmRename() {
|
||||
if (renamingScan) {
|
||||
onRename?.(renamingScan.id, draftName);
|
||||
}
|
||||
setRenamingScan(null);
|
||||
setDraftName("");
|
||||
}
|
||||
|
||||
function handleCancelRename() {
|
||||
setRenamingScan(null);
|
||||
setDraftName("");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomSheet
|
||||
ref={ref}
|
||||
index={defaultIndex}
|
||||
snapPoints={snapPoints}
|
||||
handleIndicatorStyle={styles.handleIndicator}
|
||||
backgroundStyle={styles.background}
|
||||
enablePanDownToClose={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("map.scannedPlants")}</Text>
|
||||
<Text style={styles.count}>
|
||||
{t("map.plantCount", { count: scans.length })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{scans.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.emptyIconWrap}>
|
||||
<MapPin size={32} color={colors.primary[800]} strokeWidth={2} />
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>{t("map.empty.title")}</Text>
|
||||
<Text style={styles.emptySubtitle}>{t("map.empty.subtitle")}</Text>
|
||||
{onScanCta && (
|
||||
<Pressable
|
||||
onPress={onScanCta}
|
||||
style={({ pressed }) => [
|
||||
styles.emptyCta,
|
||||
pressed && { opacity: 0.85 },
|
||||
]}
|
||||
>
|
||||
<ScanLine size={18} color="#FFFFFF" strokeWidth={2.2} />
|
||||
<Text style={styles.emptyCtaLabel}>{t("map.empty.cta")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{scans.map((scan, index) => (
|
||||
<ScanRow
|
||||
key={scan.id}
|
||||
scan={scan}
|
||||
isLast={index === scans.length - 1}
|
||||
onPress={() => onScanPress?.(scan)}
|
||||
onEdit={() => handleStartRename(scan)}
|
||||
/>
|
||||
))}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</BottomSheet>
|
||||
|
||||
<Modal
|
||||
visible={renamingScan !== null}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={handleCancelRename}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={styles.modalOverlay}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={handleCancelRename} />
|
||||
<View style={styles.modalCard}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>{t("map.rename.title")}</Text>
|
||||
<Pressable onPress={handleCancelRename} hitSlop={10}>
|
||||
<X size={20} color={colors.neutral[600]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.modalSubtitle}>{t("map.rename.subtitle")}</Text>
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
value={draftName}
|
||||
onChangeText={setDraftName}
|
||||
placeholder={t("map.rename.placeholder")}
|
||||
placeholderTextColor={colors.neutral[400]}
|
||||
autoFocus
|
||||
maxLength={64}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleConfirmRename}
|
||||
/>
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable
|
||||
onPress={handleCancelRename}
|
||||
style={({ pressed }) => [
|
||||
styles.modalButton,
|
||||
styles.modalButtonGhost,
|
||||
pressed && { opacity: 0.7 },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.modalButtonGhostLabel}>
|
||||
{t("common.cancel")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleConfirmRename}
|
||||
style={({ pressed }) => [
|
||||
styles.modalButton,
|
||||
styles.modalButtonPrimary,
|
||||
pressed && { opacity: 0.85 },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.modalButtonPrimaryLabel}>
|
||||
{t("map.rename.save")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
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" },
|
||||
};
|
||||
|
||||
interface ScanRowProps {
|
||||
scan: ScanRecord;
|
||||
isLast: boolean;
|
||||
onPress: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
function ScanRow({ scan, isLast, onPress, onEdit }: ScanRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const status = getScanStatus(scan);
|
||||
const tint = STATUS_TINT[status];
|
||||
const Icon =
|
||||
status === "healthy" ? Leaf : status === "infected" ? AlertTriangle : Clock;
|
||||
|
||||
const displayName = getScanDisplayName(scan, t);
|
||||
const formattedDate = new Date(scan.createdAt).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.row, !isLast && styles.rowBorder]}>
|
||||
<Pressable onPress={onPress} style={styles.rowMain}>
|
||||
<View style={[styles.iconBadge, { backgroundColor: tint.bg }]}>
|
||||
<Icon size={22} color={tint.fg} strokeWidth={2.2} />
|
||||
</View>
|
||||
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.location} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text style={styles.region} numberOfLines={1}>
|
||||
{formattedDate}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<Pressable onPress={onEdit} hitSlop={8} style={styles.editPencil}>
|
||||
<Pencil size={16} color={colors.neutral[600]} strokeWidth={2} />
|
||||
</Pressable>
|
||||
<Pressable onPress={onPress} hitSlop={8}>
|
||||
<ChevronRight size={20} color={colors.neutral[400]} strokeWidth={2} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
background: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12,
|
||||
elevation: 12,
|
||||
},
|
||||
handleIndicator: {
|
||||
backgroundColor: colors.neutral[300],
|
||||
width: 40,
|
||||
height: 4,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
color: colors.neutral[900],
|
||||
},
|
||||
count: {
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
color: colors.neutral[500],
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
rowBorder: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.neutral[200],
|
||||
},
|
||||
rowMain: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
},
|
||||
iconBadge: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
rowText: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
location: {
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
color: colors.neutral[900],
|
||||
},
|
||||
region: {
|
||||
fontSize: 13,
|
||||
color: colors.neutral[600],
|
||||
},
|
||||
editPencil: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 999,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: colors.neutral[100],
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 24,
|
||||
gap: 8,
|
||||
},
|
||||
emptyIconWrap: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.primary[100],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
color: colors.neutral[900],
|
||||
textAlign: "center",
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 13,
|
||||
color: colors.neutral[600],
|
||||
textAlign: "center",
|
||||
lineHeight: 18,
|
||||
marginBottom: 4,
|
||||
},
|
||||
emptyCta: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
backgroundColor: colors.primary[800],
|
||||
paddingHorizontal: 22,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 999,
|
||||
marginTop: 8,
|
||||
},
|
||||
emptyCtaLabel: {
|
||||
color: "#FFFFFF",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
modalBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.45)",
|
||||
},
|
||||
modalCard: {
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
color: colors.neutral[900],
|
||||
},
|
||||
modalSubtitle: {
|
||||
fontSize: 13,
|
||||
color: colors.neutral[600],
|
||||
lineHeight: 18,
|
||||
},
|
||||
modalInput: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.neutral[300],
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 15,
|
||||
color: colors.neutral[900],
|
||||
backgroundColor: "#FAFAFA",
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
marginTop: 4,
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
modalButtonGhost: {
|
||||
backgroundColor: colors.neutral[100],
|
||||
},
|
||||
modalButtonGhostLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
color: colors.neutral[800],
|
||||
},
|
||||
modalButtonPrimary: {
|
||||
backgroundColor: colors.primary[800],
|
||||
},
|
||||
modalButtonPrimaryLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
});
|
||||
214
VinEye/src/components/map/MapView.tsx
Normal file
214
VinEye/src/components/map/MapView.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import { StyleSheet } from "react-native";
|
||||
import WebView, { type WebViewMessageEvent } from "react-native-webview";
|
||||
|
||||
import { colors } from "@/theme/colors";
|
||||
import { getScanStatus } from "@/types/detection";
|
||||
import type { ScanRecord, ScanStatus } from "@/types/detection";
|
||||
|
||||
export interface MapRegion {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
latitudeDelta: number;
|
||||
longitudeDelta: number;
|
||||
}
|
||||
|
||||
export interface UserLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface VineyardMapHandle {
|
||||
animateToRegion: (region: MapRegion, durationMs?: number) => void;
|
||||
highlightGeoJSON: (geojson: object | null) => void;
|
||||
setUserLocation: (location: UserLocation | null) => void;
|
||||
}
|
||||
|
||||
interface VineyardMapViewProps {
|
||||
scans: ScanRecord[];
|
||||
initialRegion: MapRegion;
|
||||
onScanPress?: (scan: ScanRecord) => void;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<ScanStatus, string> = {
|
||||
healthy: colors.primary[800],
|
||||
infected: "#E63946",
|
||||
uncertain: "#F4A261",
|
||||
};
|
||||
|
||||
interface MapMarker {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function buildMarkers(scans: ScanRecord[]): MapMarker[] {
|
||||
return scans
|
||||
.filter(
|
||||
(s): s is ScanRecord & { latitude: number; longitude: number } =>
|
||||
typeof s.latitude === "number" && typeof s.longitude === "number"
|
||||
)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
lat: s.latitude,
|
||||
lng: s.longitude,
|
||||
color: STATUS_COLOR[getScanStatus(s)],
|
||||
}));
|
||||
}
|
||||
|
||||
function deltaToZoom(latitudeDelta: number): number {
|
||||
return Math.max(2, Math.min(19, Math.round(Math.log2(360 / latitudeDelta))));
|
||||
}
|
||||
|
||||
function buildHtml(markers: MapMarker[], region: MapRegion): string {
|
||||
const zoom = deltaToZoom(region.latitudeDelta);
|
||||
const markersJson = JSON.stringify(markers);
|
||||
const accentColor = colors.primary[800];
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<style>
|
||||
html, body, #map { margin: 0; padding: 0; height: 100%; width: 100%; background: #F5F5F5; }
|
||||
.vineye-marker { width: 36px; height: 36px; border-radius: 10px; border: 3px solid #fff; transform: rotate(-45deg); display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 6px rgba(0,0,0,0.25); }
|
||||
.vineye-marker svg { transform: rotate(45deg); }
|
||||
.vineye-user-wrap { position: relative; width: 44px; height: 44px; }
|
||||
.vineye-user { box-sizing: border-box; position: absolute; top: 0; left: 0; width: 44px; height: 44px; border-radius: 50%; background: ${accentColor}; border: 3px solid #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 6px 14px rgba(45,106,79,0.45); }
|
||||
.vineye-user-pulse { position: absolute; top: 50%; left: 50%; width: 44px; height: 44px; margin-left: -22px; margin-top: -22px; border-radius: 50%; background: ${accentColor}; opacity: 0.25; animation: vineye-pulse 1.6s ease-out infinite; }
|
||||
@keyframes vineye-pulse { 0% { transform: scale(1); opacity: 0.5; } 100% { transform: scale(2); opacity: 0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
var ACCENT = '${accentColor}';
|
||||
var map = L.map('map', { zoomControl: false, attributionControl: false })
|
||||
.setView([${region.latitude}, ${region.longitude}], ${zoom});
|
||||
L.tileLayer('https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
|
||||
|
||||
var leafIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 20A7 7 0 0 1 4 13C4 7.5 11 4 21 4c0 9.5-3 16-10 16Z"/><path d="M2 22 17 7"/></svg>';
|
||||
var smileIcon = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>';
|
||||
|
||||
var markers = ${markersJson};
|
||||
markers.forEach(function (m) {
|
||||
var icon = L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="vineye-marker" style="background:' + m.color + '">' + leafIcon + '</div>',
|
||||
iconSize: [44, 44],
|
||||
iconAnchor: [22, 44]
|
||||
});
|
||||
L.marker([m.lat, m.lng], { icon: icon })
|
||||
.on('click', function () {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'scan_press', id: m.id }));
|
||||
})
|
||||
.addTo(map);
|
||||
});
|
||||
|
||||
window.__vineyeAnimate = function (lat, lng, latDelta) {
|
||||
map.flyTo([lat, lng], Math.max(2, Math.min(19, Math.round(Math.log2(360 / latDelta)))), { duration: 0.6 });
|
||||
};
|
||||
|
||||
var highlightLayer = null;
|
||||
window.__vineyeHighlightGeoJSON = function (gj) {
|
||||
if (highlightLayer) { map.removeLayer(highlightLayer); highlightLayer = null; }
|
||||
if (!gj) return;
|
||||
try {
|
||||
highlightLayer = L.geoJSON(gj, {
|
||||
style: {
|
||||
color: ACCENT,
|
||||
weight: 2.5,
|
||||
fillColor: ACCENT,
|
||||
fillOpacity: 0.12,
|
||||
dashArray: '6, 8',
|
||||
lineJoin: 'round'
|
||||
}
|
||||
}).addTo(map);
|
||||
} catch (e) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'error', message: 'geojson_failed:' + (e && e.message ? e.message : 'unknown') }));
|
||||
}
|
||||
};
|
||||
|
||||
var userMarker = null;
|
||||
window.__vineyeSetUser = function (lat, lng) {
|
||||
if (userMarker) { map.removeLayer(userMarker); userMarker = null; }
|
||||
if (lat == null) return;
|
||||
var icon = L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="vineye-user-wrap"><div class="vineye-user-pulse"></div><div class="vineye-user">' + smileIcon + '</div></div>',
|
||||
iconSize: [44, 44],
|
||||
iconAnchor: [22, 22]
|
||||
});
|
||||
userMarker = L.marker([lat, lng], { icon: icon, zIndexOffset: 1000 }).addTo(map);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export const VineyardMapView = forwardRef<VineyardMapHandle, VineyardMapViewProps>(
|
||||
function VineyardMapView({ scans, initialRegion, onScanPress }, ref) {
|
||||
const webRef = useRef<WebView>(null);
|
||||
|
||||
const markers = useMemo(() => buildMarkers(scans), [scans]);
|
||||
const html = useMemo(() => buildHtml(markers, initialRegion), [markers, initialRegion]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
animateToRegion(region: MapRegion) {
|
||||
webRef.current?.injectJavaScript(
|
||||
`window.__vineyeAnimate(${region.latitude}, ${region.longitude}, ${region.latitudeDelta}); true;`
|
||||
);
|
||||
},
|
||||
highlightGeoJSON(gj: object | null) {
|
||||
const payload = gj === null ? "null" : JSON.stringify(gj);
|
||||
webRef.current?.injectJavaScript(`window.__vineyeHighlightGeoJSON(${payload}); true;`);
|
||||
},
|
||||
setUserLocation(loc: UserLocation | null) {
|
||||
if (loc === null) {
|
||||
webRef.current?.injectJavaScript(`window.__vineyeSetUser(null, null); true;`);
|
||||
} else {
|
||||
webRef.current?.injectJavaScript(
|
||||
`window.__vineyeSetUser(${loc.latitude}, ${loc.longitude}); true;`
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
function handleMessage(event: WebViewMessageEvent) {
|
||||
try {
|
||||
const data = JSON.parse(event.nativeEvent.data) as { type: string; id?: string; message?: string };
|
||||
if (data.type === "scan_press" && data.id) {
|
||||
const scan = scans.find((s) => s.id === data.id);
|
||||
if (scan) onScanPress?.(scan);
|
||||
} else if (data.type === "error" && __DEV__) {
|
||||
console.warn("[MapView]", data.message);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<WebView
|
||||
ref={webRef}
|
||||
style={StyleSheet.absoluteFill}
|
||||
originWhitelist={["*"]}
|
||||
source={{ html }}
|
||||
onMessage={handleMessage}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
scrollEnabled={false}
|
||||
bounces={false}
|
||||
androidLayerType="hardware"
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
46
VinEye/src/data/wineRegions.ts
Normal file
46
VinEye/src/data/wineRegions.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export interface WineRegion {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
latitudeDelta: number;
|
||||
longitudeDelta: number;
|
||||
geojsonUrl: string;
|
||||
}
|
||||
|
||||
const GEOJSON_BASE =
|
||||
'https://france-geojson.gregoiredavid.fr/repo/departements';
|
||||
|
||||
export const WINE_REGIONS: WineRegion[] = [
|
||||
{
|
||||
id: 'bordeaux',
|
||||
labelKey: 'map.regions.bordeaux',
|
||||
latitude: 44.84,
|
||||
longitude: -0.58,
|
||||
latitudeDelta: 1.6,
|
||||
longitudeDelta: 1.6,
|
||||
geojsonUrl: `${GEOJSON_BASE}/33-gironde/departement-33-gironde.geojson`,
|
||||
},
|
||||
{
|
||||
id: 'burgundy',
|
||||
labelKey: 'map.regions.burgundy',
|
||||
latitude: 47.32,
|
||||
longitude: 4.83,
|
||||
latitudeDelta: 1.8,
|
||||
longitudeDelta: 1.8,
|
||||
geojsonUrl: `${GEOJSON_BASE}/21-cote-d-or/departement-21-cote-d-or.geojson`,
|
||||
},
|
||||
{
|
||||
id: 'champagne',
|
||||
labelKey: 'map.regions.champagne',
|
||||
latitude: 48.95,
|
||||
longitude: 4.05,
|
||||
latitudeDelta: 1.4,
|
||||
longitudeDelta: 1.4,
|
||||
geojsonUrl: `${GEOJSON_BASE}/51-marne/departement-51-marne.geojson`,
|
||||
},
|
||||
];
|
||||
|
||||
export function getRegionById(id: string): WineRegion | undefined {
|
||||
return WINE_REGIONS.find((r) => r.id === id);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { storage } from '@/services/storage';
|
||||
import { buildMockScans } from '@/data/mockSeed';
|
||||
import type { ScanRecord } from '@/types/detection';
|
||||
|
||||
export function useHistory() {
|
||||
|
|
@ -43,10 +44,40 @@ export function useHistory() {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const renameScan = useCallback(async (id: string, name: string) => {
|
||||
const trimmed = name.trim();
|
||||
setHistory((prev) => {
|
||||
const updated = prev.map((r) =>
|
||||
r.id === id ? { ...r, customName: trimmed.length > 0 ? trimmed : undefined } : r
|
||||
);
|
||||
storage.set(storage.KEYS.SCAN_HISTORY, updated);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearHistory = useCallback(async () => {
|
||||
await storage.remove(storage.KEYS.SCAN_HISTORY);
|
||||
setHistory([]);
|
||||
}, []);
|
||||
|
||||
return { history, isLoading, addScan, deleteScan, toggleFavorite, clearHistory, reload: loadHistory };
|
||||
const seedTestData = useCallback(async () => {
|
||||
const mocks = buildMockScans();
|
||||
setHistory((prev) => {
|
||||
const updated = [...mocks, ...prev];
|
||||
storage.set(storage.KEYS.SCAN_HISTORY, updated);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
history,
|
||||
isLoading,
|
||||
addScan,
|
||||
deleteScan,
|
||||
toggleFavorite,
|
||||
renameScan,
|
||||
clearHistory,
|
||||
seedTestData,
|
||||
reload: loadHistory,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,26 @@
|
|||
"part3": "Clusters (desiccation)",
|
||||
"spread": "Transmitted by the leafhopper Scaphoideus titanus"
|
||||
},
|
||||
"leafBlight": {
|
||||
"name": "Leaf Blight",
|
||||
"description": "Leaf blight (Isariopsis Leaf Spot) is caused by the fungus Pseudocercospora vitis. It produces angular reddish-brown spots delimited by leaf veins.",
|
||||
"symptom1": "Angular reddish-brown spots delimited by veins",
|
||||
"symptom2": "Yellow halo around spots",
|
||||
"symptom3": "Early defoliation in severe attacks",
|
||||
"treatment": "Preventive copper or mancozeb-based fungicide treatment. Remove fallen leaves in autumn.",
|
||||
"season": "July to September",
|
||||
"condition1": "Prolonged high humidity",
|
||||
"condition2": "Temperatures between 20 and 28°C",
|
||||
"condition3": "Weakened or stressed vines",
|
||||
"preventive1": "Preventive copper treatment",
|
||||
"preventive2": "Remove infected leaves in autumn",
|
||||
"preventive3": "Maintain good foliage ventilation",
|
||||
"curative1": "Apply a mancozeb-based fungicide",
|
||||
"curative2": "Remove severely affected leaves",
|
||||
"part1": "Leaves",
|
||||
"part2": "Shoots (rare)",
|
||||
"spread": "Spores spread by rain and wind"
|
||||
},
|
||||
"chlorose": {
|
||||
"name": "Iron chlorosis",
|
||||
"description": "Leaf yellowing due to iron deficiency, often linked to overly calcareous soil.",
|
||||
|
|
@ -375,6 +395,15 @@
|
|||
"vineDetected": "Vine detected!",
|
||||
"notVine": "This is not a vine",
|
||||
"uncertain": "Uncertain result",
|
||||
"uncertainTitle": "Uncertain analysis",
|
||||
"uncertainMessage": "The model is not confident enough. Take a sharper, well-lit photo centered on a leaf.",
|
||||
"healthy": "Healthy vine",
|
||||
"healthyTitle": "Healthy vine",
|
||||
"healthyMessage": "No disease detected. Keep monitoring your vines regularly.",
|
||||
"detectedDisease": "Disease detected",
|
||||
"viewDiseaseDetail": "View disease details",
|
||||
"allProbabilities": "Probabilities by class",
|
||||
"confidence": "confidence",
|
||||
"grape": "Probable variety",
|
||||
"origin": "Origin",
|
||||
"characteristics": "Characteristics",
|
||||
|
|
@ -387,6 +416,9 @@
|
|||
"white": "White",
|
||||
"rose": "Rosé"
|
||||
},
|
||||
"detection": {
|
||||
"healthy": "Healthy vine"
|
||||
},
|
||||
"history": {
|
||||
"title": "History",
|
||||
"empty": "No scans in history",
|
||||
|
|
@ -427,7 +459,10 @@
|
|||
"helpCenter": "Help Center",
|
||||
"terms": "Terms of Use",
|
||||
"referTitle": "Refer a friend",
|
||||
"referBody": "Share VinEye and earn bonus XP for every friend you invite."
|
||||
"referBody": "Share VinEye and earn bonus XP for every friend you invite.",
|
||||
"developer": "Developer",
|
||||
"seedTestData": "Add mock plants",
|
||||
"seedDone": "5 mock plants added"
|
||||
},
|
||||
"achievements": {
|
||||
"firstScan": "First Scan",
|
||||
|
|
@ -458,5 +493,37 @@
|
|||
"cellarMaster": "Cellar Master",
|
||||
"level": "Level {{level}}",
|
||||
"xpToNext": "{{xp}} XP to next level"
|
||||
},
|
||||
"map": {
|
||||
"searchPlaceholder": "Search a scanned plant...",
|
||||
"scannedPlants": "Scanned plants",
|
||||
"plantCount_one": "{{count}} plant",
|
||||
"plantCount_other": "{{count}} plants",
|
||||
"filters": {
|
||||
"myLocation": "My location"
|
||||
},
|
||||
"regions": {
|
||||
"bordeaux": "Bordeaux",
|
||||
"burgundy": "Burgundy",
|
||||
"champagne": "Champagne"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No geolocated plant yet",
|
||||
"subtitle": "Enable location, then scan a plant to see it appear on the map.",
|
||||
"cta": "Scan a plant"
|
||||
},
|
||||
"comingSoon": "Coming soon",
|
||||
"regionLoadFailed": "Could not load region outline",
|
||||
"rename": {
|
||||
"title": "Rename plant",
|
||||
"subtitle": "Give this plant a custom name so you can recognize it easily.",
|
||||
"placeholder": "E.g. Garden vine",
|
||||
"save": "Save"
|
||||
}
|
||||
},
|
||||
"location": {
|
||||
"permissionDenied": "Location access denied — your scans won't appear on the map",
|
||||
"permissionDeniedTitle": "Location",
|
||||
"settingsHint": "You can enable it in Settings"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,26 @@
|
|||
"part3": "Grappes (dessèchement)",
|
||||
"spread": "Transmis par la cicadelle Scaphoideus titanus"
|
||||
},
|
||||
"leafBlight": {
|
||||
"name": "Brûlure des feuilles",
|
||||
"description": "La brûlure des feuilles (Isariopsis Leaf Spot) est causée par le champignon Pseudocercospora vitis. Elle provoque des taches angulaires brun-rougeâtre délimitées par les nervures.",
|
||||
"symptom1": "Taches angulaires brun-rougeâtre délimitées par les nervures",
|
||||
"symptom2": "Halo jaune autour des taches",
|
||||
"symptom3": "Défoliation précoce en cas d'attaque sévère",
|
||||
"treatment": "Traitement fongicide préventif à base de cuivre ou mancozèbe. Éliminer les feuilles tombées à l'automne.",
|
||||
"season": "Juillet à septembre",
|
||||
"condition1": "Humidité élevée prolongée",
|
||||
"condition2": "Températures entre 20 et 28°C",
|
||||
"condition3": "Vignes affaiblies ou stressées",
|
||||
"preventive1": "Traitement cuivrique préventif",
|
||||
"preventive2": "Éliminer les feuilles infectées à l'automne",
|
||||
"preventive3": "Maintenir une bonne aération du feuillage",
|
||||
"curative1": "Appliquer un fongicide à base de mancozèbe",
|
||||
"curative2": "Retirer les feuilles sévèrement atteintes",
|
||||
"part1": "Feuilles",
|
||||
"part2": "Rameaux (rare)",
|
||||
"spread": "Spores disséminées par la pluie et le vent"
|
||||
},
|
||||
"chlorose": {
|
||||
"name": "Chlorose ferrique",
|
||||
"description": "Jaunissement des feuilles dû à une carence en fer, souvent lié à un sol trop calcaire.",
|
||||
|
|
@ -375,6 +395,15 @@
|
|||
"vineDetected": "Vigne détectée !",
|
||||
"notVine": "Ce n'est pas une vigne",
|
||||
"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.",
|
||||
"healthy": "Vigne saine",
|
||||
"healthyTitle": "Vigne en bonne santé",
|
||||
"healthyMessage": "Aucune maladie détectée. Continuez la surveillance régulière de vos vignes.",
|
||||
"detectedDisease": "Maladie détectée",
|
||||
"viewDiseaseDetail": "Voir le détail de la maladie",
|
||||
"allProbabilities": "Probabilités par classe",
|
||||
"confidence": "de confiance",
|
||||
"grape": "Cépage probable",
|
||||
"origin": "Origine",
|
||||
"characteristics": "Caractéristiques",
|
||||
|
|
@ -387,6 +416,9 @@
|
|||
"white": "Blanc",
|
||||
"rose": "Rosé"
|
||||
},
|
||||
"detection": {
|
||||
"healthy": "Vigne saine"
|
||||
},
|
||||
"history": {
|
||||
"title": "Historique",
|
||||
"empty": "Aucun scan dans l'historique",
|
||||
|
|
@ -427,7 +459,10 @@
|
|||
"helpCenter": "Centre d'aide",
|
||||
"terms": "Conditions d'utilisation",
|
||||
"referTitle": "Inviter un ami",
|
||||
"referBody": "Partagez VinEye et gagnez des XP bonus pour chaque ami invité."
|
||||
"referBody": "Partagez VinEye et gagnez des XP bonus pour chaque ami invité.",
|
||||
"developer": "Développeur",
|
||||
"seedTestData": "Ajouter des plantes fictives",
|
||||
"seedDone": "5 plantes fictives ajoutées"
|
||||
},
|
||||
"achievements": {
|
||||
"firstScan": "Premier Scan",
|
||||
|
|
@ -458,5 +493,37 @@
|
|||
"cellarMaster": "Maître de Chai",
|
||||
"level": "Niveau {{level}}",
|
||||
"xpToNext": "{{xp}} XP pour le prochain niveau"
|
||||
},
|
||||
"map": {
|
||||
"searchPlaceholder": "Rechercher une plante scannée...",
|
||||
"scannedPlants": "Plantes scannées",
|
||||
"plantCount_one": "{{count}} plante",
|
||||
"plantCount_other": "{{count}} plantes",
|
||||
"filters": {
|
||||
"myLocation": "Ma position"
|
||||
},
|
||||
"regions": {
|
||||
"bordeaux": "Bordeaux",
|
||||
"burgundy": "Bourgogne",
|
||||
"champagne": "Champagne"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aucune plante géolocalisée",
|
||||
"subtitle": "Activez la géolocalisation puis scannez une plante pour la voir apparaître sur la carte.",
|
||||
"cta": "Scanner une plante"
|
||||
},
|
||||
"comingSoon": "Bientôt disponible",
|
||||
"regionLoadFailed": "Impossible de charger les contours de la région",
|
||||
"rename": {
|
||||
"title": "Renommer la plante",
|
||||
"subtitle": "Donnez un nom personnalisé à cette plante pour la retrouver plus facilement.",
|
||||
"placeholder": "Ex. Vigne du jardin",
|
||||
"save": "Enregistrer"
|
||||
}
|
||||
},
|
||||
"location": {
|
||||
"permissionDenied": "Géolocalisation refusée — vos scans n'apparaîtront pas sur la carte",
|
||||
"permissionDeniedTitle": "Géolocalisation",
|
||||
"settingsHint": "Vous pouvez l'activer dans les Réglages"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,234 @@
|
|||
import { View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useFocusEffect, useNavigation } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import BottomSheet from "@gorhom/bottom-sheet";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { toast } from "sonner-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { colors } from "@/theme/colors";
|
||||
import { VineyardMapView, type VineyardMapHandle, type MapRegion } from "@/components/map/MapView";
|
||||
import { FloatingSearch } from "@/components/map/FloatingSearch";
|
||||
import { FloatingActions } from "@/components/map/FloatingActions";
|
||||
import { MapBottomSheet } from "@/components/map/MapBottomSheet";
|
||||
import { useHistory } from "@/hooks/useHistory";
|
||||
import { useScanLocation } from "@/hooks/useScanLocation";
|
||||
import { getRegionById } from "@/data/wineRegions";
|
||||
import type { ScanRecord } from "@/types/detection";
|
||||
import type { RootStackParamList } from "@/types/navigation";
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const DEFAULT_REGION: MapRegion = {
|
||||
latitude: 44.8378,
|
||||
longitude: -0.5792,
|
||||
latitudeDelta: 0.12,
|
||||
longitudeDelta: 0.12,
|
||||
};
|
||||
|
||||
function hasLocation(scan: ScanRecord): scan is ScanRecord & { latitude: number; longitude: number } {
|
||||
return typeof scan.latitude === "number" && typeof scan.longitude === "number";
|
||||
}
|
||||
|
||||
function computeInitialRegion(scans: ScanRecord[]): MapRegion {
|
||||
const located = scans.filter(hasLocation);
|
||||
if (located.length === 0) return DEFAULT_REGION;
|
||||
if (located.length === 1) {
|
||||
return {
|
||||
latitude: located[0].latitude,
|
||||
longitude: located[0].longitude,
|
||||
latitudeDelta: 0.04,
|
||||
longitudeDelta: 0.04,
|
||||
};
|
||||
}
|
||||
const lats = located.map((s) => s.latitude);
|
||||
const lngs = located.map((s) => s.longitude);
|
||||
const minLat = Math.min(...lats);
|
||||
const maxLat = Math.max(...lats);
|
||||
const minLng = Math.min(...lngs);
|
||||
const maxLng = Math.max(...lngs);
|
||||
return {
|
||||
latitude: (minLat + maxLat) / 2,
|
||||
longitude: (minLng + maxLng) / 2,
|
||||
latitudeDelta: Math.max((maxLat - minLat) * 1.4, 0.04),
|
||||
longitudeDelta: Math.max((maxLng - minLng) * 1.4, 0.04),
|
||||
};
|
||||
}
|
||||
|
||||
export default function MapScreen() {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { history, renameScan, reload } = useHistory();
|
||||
const { requestAndGetLocation } = useScanLocation();
|
||||
const mapRef = useRef<VineyardMapHandle>(null);
|
||||
const sheetRef = useRef<BottomSheet>(null);
|
||||
const geojsonCache = useRef<Map<string, object>>(new Map());
|
||||
const activeFilterRef = useRef<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<string | null>(null);
|
||||
activeFilterRef.current = activeFilter;
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
reload();
|
||||
}, [reload])
|
||||
);
|
||||
|
||||
const locatedScans = useMemo(() => history.filter(hasLocation), [history]);
|
||||
const initialRegion = useMemo(() => computeInitialRegion(history), [history]);
|
||||
|
||||
function handleScanPress(scan: ScanRecord) {
|
||||
if (hasLocation(scan)) {
|
||||
mapRef.current?.animateToRegion({
|
||||
latitude: scan.latitude,
|
||||
longitude: scan.longitude,
|
||||
latitudeDelta: 0.04,
|
||||
longitudeDelta: 0.04,
|
||||
});
|
||||
}
|
||||
setActiveFilter(null);
|
||||
mapRef.current?.highlightGeoJSON(null);
|
||||
navigation.navigate("ScanDetail", { scanId: scan.id });
|
||||
}
|
||||
|
||||
async function handleLocateUser() {
|
||||
const coords = await requestAndGetLocation();
|
||||
if (coords) {
|
||||
mapRef.current?.animateToRegion({
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
latitudeDelta: 0.02,
|
||||
longitudeDelta: 0.02,
|
||||
});
|
||||
mapRef.current?.setUserLocation({
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
});
|
||||
mapRef.current?.highlightGeoJSON(null);
|
||||
setActiveFilter("myLocation");
|
||||
} else {
|
||||
toast.info(t("location.permissionDeniedTitle"), {
|
||||
description: t("location.permissionDenied"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleComingSoon() {
|
||||
toast.info(t("map.comingSoon"));
|
||||
}
|
||||
|
||||
async function fetchRegionGeoJSON(url: string): Promise<object | null> {
|
||||
const cached = geojsonCache.current.get(url);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = (await res.json()) as object;
|
||||
geojsonCache.current.set(url, json);
|
||||
return json;
|
||||
} catch (err) {
|
||||
if (__DEV__) console.warn("[MapScreen] geojson fetch failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilterPress(id: string) {
|
||||
if (id === "myLocation") {
|
||||
handleLocateUser();
|
||||
return;
|
||||
}
|
||||
const region = getRegionById(id);
|
||||
if (!region) return;
|
||||
|
||||
setActiveFilter(id);
|
||||
mapRef.current?.animateToRegion({
|
||||
latitude: region.latitude,
|
||||
longitude: region.longitude,
|
||||
latitudeDelta: region.latitudeDelta,
|
||||
longitudeDelta: region.longitudeDelta,
|
||||
});
|
||||
|
||||
const geojson = await fetchRegionGeoJSON(region.geojsonUrl);
|
||||
if (geojson && activeFilterRef.current === id) {
|
||||
mapRef.current?.highlightGeoJSON(geojson);
|
||||
} else if (!geojson) {
|
||||
toast.error(t("map.regionLoadFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleRename(scanId: string, newName: string) {
|
||||
renameScan(scanId, newName);
|
||||
}
|
||||
|
||||
const isEmpty = locatedScans.length === 0;
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}>
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<View
|
||||
className="mb-6 h-20 w-20 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: colors.primary[100] }}
|
||||
>
|
||||
<Ionicons name="map-outline" size={36} color={colors.primary[700]} />
|
||||
</View>
|
||||
<Text className="mb-2 text-xl font-semibold" style={{ color: colors.neutral[900] }}>
|
||||
{t("common.map")}
|
||||
</Text>
|
||||
<Text className="text-center text-sm" style={{ color: colors.neutral[500] }}>
|
||||
Coming soon
|
||||
</Text>
|
||||
<View style={styles.root}>
|
||||
<VineyardMapView
|
||||
ref={mapRef}
|
||||
scans={locatedScans}
|
||||
initialRegion={initialRegion}
|
||||
onScanPress={handleScanPress}
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
colors={["rgba(255,255,255,0.95)", "rgba(255,255,255,0)"]}
|
||||
style={[styles.topGradient, { height: insets.top + 140 }]}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[styles.searchSlot, { paddingTop: insets.top + 8 }]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<FloatingSearch activeFilter={activeFilter} onFilterPress={handleFilterPress} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
<MapBottomSheet
|
||||
ref={sheetRef}
|
||||
scans={locatedScans}
|
||||
onScanPress={handleScanPress}
|
||||
onRename={handleRename}
|
||||
onScanCta={() => navigation.navigate("Main", { screen: "Scanner" })}
|
||||
defaultIndex={isEmpty ? 1 : 0}
|
||||
/>
|
||||
|
||||
<View style={styles.actionsSlot} pointerEvents="box-none">
|
||||
<FloatingActions
|
||||
onLocate={handleLocateUser}
|
||||
onLayers={handleComingSoon}
|
||||
onSatellite={handleComingSoon}
|
||||
activeAction={activeFilter === "myLocation" ? "locate" : undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: "#F5F5F5",
|
||||
},
|
||||
topGradient: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
searchSlot: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
actionsSlot: {
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
top: "30%",
|
||||
zIndex: 10,
|
||||
elevation: 10,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
17
VinEye/src/utils/scanDisplay.ts
Normal file
17
VinEye/src/utils/scanDisplay.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { getCepageById } from './cepages';
|
||||
import type { ScanRecord } from '@/types/detection';
|
||||
|
||||
export function getScanDisplayName(scan: ScanRecord, t: TFunction): string {
|
||||
if (scan.customName && scan.customName.trim().length > 0) {
|
||||
return scan.customName.trim();
|
||||
}
|
||||
if (scan.detection.cepageId) {
|
||||
const cepage = getCepageById(scan.detection.cepageId);
|
||||
if (cepage) return cepage.name.fr;
|
||||
}
|
||||
if (scan.detection.result === 'vine') return t('result.vineDetected');
|
||||
if (scan.detection.result === 'uncertain') return t('result.uncertain');
|
||||
return t('result.notVine');
|
||||
}
|
||||
Loading…
Reference in a new issue