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:
Yanis 2026-04-30 12:32:14 +02:00
parent 3be2d3b531
commit d30f4f250c
10 changed files with 1360 additions and 23 deletions

View 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,
},
});

View 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",
},
});

View 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",
},
});

View 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"
/>
);
}
);

View 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);
}

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { storage } from '@/services/storage'; import { storage } from '@/services/storage';
import { buildMockScans } from '@/data/mockSeed';
import type { ScanRecord } from '@/types/detection'; import type { ScanRecord } from '@/types/detection';
export function useHistory() { 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 () => { const clearHistory = useCallback(async () => {
await storage.remove(storage.KEYS.SCAN_HISTORY); await storage.remove(storage.KEYS.SCAN_HISTORY);
setHistory([]); 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,
};
} }

View file

@ -173,6 +173,26 @@
"part3": "Clusters (desiccation)", "part3": "Clusters (desiccation)",
"spread": "Transmitted by the leafhopper Scaphoideus titanus" "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": { "chlorose": {
"name": "Iron chlorosis", "name": "Iron chlorosis",
"description": "Leaf yellowing due to iron deficiency, often linked to overly calcareous soil.", "description": "Leaf yellowing due to iron deficiency, often linked to overly calcareous soil.",
@ -375,6 +395,15 @@
"vineDetected": "Vine detected!", "vineDetected": "Vine detected!",
"notVine": "This is not a vine", "notVine": "This is not a vine",
"uncertain": "Uncertain result", "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", "grape": "Probable variety",
"origin": "Origin", "origin": "Origin",
"characteristics": "Characteristics", "characteristics": "Characteristics",
@ -387,6 +416,9 @@
"white": "White", "white": "White",
"rose": "Rosé" "rose": "Rosé"
}, },
"detection": {
"healthy": "Healthy vine"
},
"history": { "history": {
"title": "History", "title": "History",
"empty": "No scans in history", "empty": "No scans in history",
@ -427,7 +459,10 @@
"helpCenter": "Help Center", "helpCenter": "Help Center",
"terms": "Terms of Use", "terms": "Terms of Use",
"referTitle": "Refer a friend", "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": { "achievements": {
"firstScan": "First Scan", "firstScan": "First Scan",
@ -458,5 +493,37 @@
"cellarMaster": "Cellar Master", "cellarMaster": "Cellar Master",
"level": "Level {{level}}", "level": "Level {{level}}",
"xpToNext": "{{xp}} XP to next 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"
} }
} }

View file

@ -173,6 +173,26 @@
"part3": "Grappes (dessèchement)", "part3": "Grappes (dessèchement)",
"spread": "Transmis par la cicadelle Scaphoideus titanus" "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": { "chlorose": {
"name": "Chlorose ferrique", "name": "Chlorose ferrique",
"description": "Jaunissement des feuilles dû à une carence en fer, souvent lié à un sol trop calcaire.", "description": "Jaunissement des feuilles dû à une carence en fer, souvent lié à un sol trop calcaire.",
@ -375,6 +395,15 @@
"vineDetected": "Vigne détectée !", "vineDetected": "Vigne détectée !",
"notVine": "Ce n'est pas une vigne", "notVine": "Ce n'est pas une vigne",
"uncertain": "Résultat incertain", "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", "grape": "Cépage probable",
"origin": "Origine", "origin": "Origine",
"characteristics": "Caractéristiques", "characteristics": "Caractéristiques",
@ -387,6 +416,9 @@
"white": "Blanc", "white": "Blanc",
"rose": "Rosé" "rose": "Rosé"
}, },
"detection": {
"healthy": "Vigne saine"
},
"history": { "history": {
"title": "Historique", "title": "Historique",
"empty": "Aucun scan dans l'historique", "empty": "Aucun scan dans l'historique",
@ -427,7 +459,10 @@
"helpCenter": "Centre d'aide", "helpCenter": "Centre d'aide",
"terms": "Conditions d'utilisation", "terms": "Conditions d'utilisation",
"referTitle": "Inviter un ami", "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": { "achievements": {
"firstScan": "Premier Scan", "firstScan": "Premier Scan",
@ -458,5 +493,37 @@
"cellarMaster": "Maître de Chai", "cellarMaster": "Maître de Chai",
"level": "Niveau {{level}}", "level": "Niveau {{level}}",
"xpToNext": "{{xp}} XP pour le prochain niveau" "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"
} }
} }

View file

@ -1,30 +1,234 @@
import { View } from "react-native"; import { useCallback, useMemo, useRef, useState } from "react";
import { SafeAreaView } from "react-native-safe-area-context"; 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 { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text"; import { VineyardMapView, type VineyardMapHandle, type MapRegion } from "@/components/map/MapView";
import { colors } from "@/theme/colors"; 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() { export default function MapScreen() {
const { t } = useTranslation(); 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 ( return (
<SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}> <View style={styles.root}>
<View className="flex-1 items-center justify-center px-8"> <VineyardMapView
<View ref={mapRef}
className="mb-6 h-20 w-20 items-center justify-center rounded-full" scans={locatedScans}
style={{ backgroundColor: colors.primary[100] }} initialRegion={initialRegion}
> onScanPress={handleScanPress}
<Ionicons name="map-outline" size={36} color={colors.primary[700]} /> />
</View>
<Text className="mb-2 text-xl font-semibold" style={{ color: colors.neutral[900] }}> <LinearGradient
{t("common.map")} colors={["rgba(255,255,255,0.95)", "rgba(255,255,255,0)"]}
</Text> style={[styles.topGradient, { height: insets.top + 140 }]}
<Text className="text-center text-sm" style={{ color: colors.neutral[500] }}> pointerEvents="none"
Coming soon />
</Text>
<View
style={[styles.searchSlot, { paddingTop: insets.top + 8 }]}
pointerEvents="box-none"
>
<FloatingSearch activeFilter={activeFilter} onFilterPress={handleFilterPress} />
</View> </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,
},
});

View 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');
}