feat(search): centralized search modal with categories + map plant distance

Une seule SearchScreen modale gère la recherche depuis Home, MyPlants,
Guides et Map. La SearchBar partagée passe en mode trigger sur ces pages,
ouvrant la modal au tap.

Animation par contexte (native-stack) :
- Home/MyPlants/Guides → 'fade_from_bottom' (fade + léger glissement)
- Map → 'fade' pur (la barre est déjà en haut, pas de mouvement vertical)
  via param de route { fromMap: true }

SearchScreen — mode global :
- 3 catégories : Maladies (red), Guides pratiques (blue), Mes plantes (green)
- Filter chips scrollable horizontal : Tout / Maladies / Guides / Plantes
  avec count par catégorie
- Sections en accordion (chevron) avec count par section
- Tag coloré sur chaque résultat indiquant la catégorie
- Plantes scannées incluses (recherche par customName / cépage)
- Tap résultat : DiseaseDetail / GuideDetail / Map+focusScanId / ScanDetail

SearchScreen — mode Map (fromMap=true) :
- Liste plate des plantes localisées uniquement
- Distance haversine depuis position courante (formatDistance helper)
- Tri par distance croissante
- Tap → navigate Main/Map avec focusScanId

MapScreen :
- useEffect sur route.params?.focusScanId : animation 2 étapes (zoom large
  450ms → zoom serré 500ms) + ouverture du preview
- Caméra décalée vers le sud (lat - delta * 0.18) pour que le marker soit
  visible AU-DESSUS du bottom sheet, pas masqué dessous
- Reset du param après usage via setParams

Recents :
- Hook useRecentSearches (AsyncStorage @vineye:recent_searches)
- Max 10, déduplication insensible à la casse
- Affichés quand pas de query, avec clear all + remove individuel

SearchBar partagée :
- Refactorisée 100% Tailwind (sauf 2-3 props RN-spécifiques sur TextInput)
- Nouvelle prop onTriggerPress : devient un Pressable avec Text placeholder
  qui navigate vers Search au lieu d'être un input local

Wirings :
- SearchSection (Home + Guides) : trigger
- MyPlantsScreen : trigger (suppression de l'inline filtering, plus utilisé)
- FloatingSearch (Map) : trigger avec fromMap: true
- RootNavigator : Stack.Screen Search avec options dynamique selon param

i18n FR + EN :
- search.{placeholder, placeholderMap, recentTitle, clearAll, noRecent,
  resultsTitle, noResults, nearbyPlantsTitle, noPlants}
- search.filter.{all, diseases, guides, plants}
- search.section.{diseases, guides, plants}
- search.tag.{disease, guide, plant}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yanis 2026-05-01 00:37:12 +02:00
parent 07f25769e3
commit 25b56092d5
13 changed files with 961 additions and 122 deletions

View file

@ -1,22 +1,23 @@
import { View, StyleSheet } from "react-native";
import { View } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTranslation } from "react-i18next";
import SearchBar from "@/components/shared/SearchBar";
import type { RootStackParamList } from "@/types/navigation";
type Nav = NativeStackNavigationProp<RootStackParamList>;
export default function SearchSection() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
return (
<View style={styles.container}>
<SearchBar placeholder={t("home.searchPlaceholder") ?? "Rechercher..."} />
<View className="px-5 pt-1 pb-4">
<SearchBar
placeholder={t("home.searchPlaceholder") ?? "Rechercher..."}
onTriggerPress={() => navigation.navigate("Search")}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 20,
paddingBottom: 16,
paddingTop: 4,
},
});

View file

@ -1,5 +1,6 @@
import { useState } from "react";
import { View, ScrollView, Pressable, StyleSheet } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTranslation } from "react-i18next";
import { MapPin } from "lucide-react-native";
@ -8,6 +9,9 @@ import SearchBar from "@/components/shared/SearchBar";
import { HeaderActionButtons } from "@/components/shared/HeaderActionButtons";
import { colors } from "@/theme/colors";
import { WINE_REGIONS } from "@/data/wineRegions";
import type { RootStackParamList } from "@/types/navigation";
type Nav = NativeStackNavigationProp<RootStackParamList>;
export type MapFilterId = "myLocation" | string;
@ -21,7 +25,7 @@ export function FloatingSearch({
onFilterPress,
}: FloatingSearchProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const navigation = useNavigation<Nav>();
const filters = [
{
@ -42,8 +46,9 @@ export function FloatingSearch({
<View className="flex-1">
<SearchBar
placeholder={t("map.searchPlaceholder")}
value={query}
onChangeText={setQuery}
onTriggerPress={() =>
navigation.navigate("Search", { fromMap: true })
}
/>
</View>
<HeaderActionButtons />

View file

@ -1,6 +1,7 @@
import { View, TextInput, TouchableOpacity, StyleSheet } from "react-native";
import { View, TextInput, Pressable } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
interface SearchBarProps {
@ -8,7 +9,9 @@ interface SearchBarProps {
value?: string;
onChangeText?: (text: string) => void;
onFilterPress?: () => void;
onTriggerPress?: () => void;
showFilter?: boolean;
autoFocus?: boolean;
}
export default function SearchBar({
@ -16,82 +19,76 @@ export default function SearchBar({
value,
onChangeText,
onFilterPress,
showFilter = true,
onTriggerPress,
showFilter = false,
autoFocus = false,
}: SearchBarProps) {
return (
<View style={styles.searchWrapper}>
<Ionicons
name="search"
size={20}
color={colors.neutral[400]}
style={styles.searchIcon}
/>
const triggerMode = !!onTriggerPress;
const wrapperClass =
"flex-row items-center bg-white rounded-full px-4 h-[52px] border border-[#EAECEF]";
<TextInput
style={styles.input}
value={value}
multiline={false}
const content = (
<>
<Ionicons name="search" size={20} color={colors.neutral[400]} />
{triggerMode ? (
<Text
className="flex-1 ml-2.5 text-[15px] font-medium text-[#9CA3AF]"
numberOfLines={1}
scrollEnabled={false}
>
{placeholder ?? "Rechercher..."}
</Text>
) : (
<TextInput
className="flex-1 ml-2.5 text-[15px] font-medium text-[#1A1A1A] h-full"
style={{
paddingVertical: 0,
paddingHorizontal: 0,
textAlignVertical: "center",
includeFontPadding: false,
}}
value={value}
onChangeText={onChangeText}
placeholder={placeholder ?? "Rechercher..."}
placeholderTextColor={colors.neutral[400]}
selectionColor={colors.primary[500]}
autoCorrect={false}
autoFocus={autoFocus}
multiline={false}
numberOfLines={1}
scrollEnabled={false}
/>
)}
{showFilter && (
<TouchableOpacity
onPress={onFilterPress}
style={styles.filterButton}
activeOpacity={0.7}
<Pressable
onPress={(e) => {
e.stopPropagation?.();
onFilterPress?.();
}}
className="flex-row items-center pl-3 active:opacity-70"
>
<View style={styles.divider} />
<View className="w-px h-5 bg-[#E2E4E7] mr-3" />
<Ionicons
name="options-outline"
size={18}
color={colors.primary[600]}
/>
</TouchableOpacity>
</Pressable>
)}
</View>
</>
);
}
const styles = StyleSheet.create({
searchWrapper: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff",
borderRadius: 100,
paddingHorizontal: 16,
height: 52,
borderWidth: 1,
borderColor: "#EAECEF",
},
searchIcon: {
marginRight: 10,
},
input: {
flex: 1,
fontSize: 15,
lineHeight: 20,
fontWeight: "500",
color: colors.neutral[900],
paddingVertical: 0,
paddingHorizontal: 0,
textAlignVertical: "center",
includeFontPadding: false,
},
filterButton: {
flexDirection: "row",
alignItems: "center",
paddingLeft: 12,
},
divider: {
width: 1,
height: 20,
backgroundColor: "#E2E4E7",
marginRight: 12,
},
});
if (triggerMode) {
return (
<Pressable
onPress={onTriggerPress}
className={`${wrapperClass} active:opacity-90`}
>
{content}
</Pressable>
);
}
return <View className={wrapperClass}>{content}</View>;
}

View file

@ -0,0 +1,63 @@
import { useCallback, useEffect, useState } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
const KEY = '@vineye:recent_searches';
const MAX_RECENTS = 10;
export function useRecentSearches() {
const [recents, setRecents] = useState<string[]>([]);
useEffect(() => {
AsyncStorage.getItem(KEY)
.then((data) => {
if (!data) return;
try {
const parsed = JSON.parse(data);
if (Array.isArray(parsed)) {
setRecents(parsed.filter((q): q is string => typeof q === 'string'));
}
} catch {
// ignore corrupt cache
}
})
.catch(() => undefined);
}, []);
const persist = useCallback((next: string[]) => {
AsyncStorage.setItem(KEY, JSON.stringify(next)).catch(() => undefined);
}, []);
const addRecent = useCallback(
(query: string) => {
const trimmed = query.trim();
if (trimmed.length === 0) return;
setRecents((prev) => {
const next = [
trimmed,
...prev.filter((q) => q.toLowerCase() !== trimmed.toLowerCase()),
].slice(0, MAX_RECENTS);
persist(next);
return next;
});
},
[persist],
);
const removeRecent = useCallback(
(query: string) => {
setRecents((prev) => {
const next = prev.filter((q) => q !== query);
persist(next);
return next;
});
},
[persist],
);
const clearRecents = useCallback(() => {
setRecents([]);
AsyncStorage.removeItem(KEY).catch(() => undefined);
}, []);
return { recents, addRecent, removeRecent, clearRecents };
}

View file

@ -22,6 +22,33 @@
"spreadMethod": "Spread method",
"timeline": "Active period"
},
"search": {
"placeholder": "What are you looking for?",
"placeholderMap": "Search one of my plants...",
"recentTitle": "Recent searches",
"clearAll": "Clear all",
"noRecent": "No recent searches yet. Type to get started.",
"resultsTitle": "Results",
"noResults": "No results found.",
"nearbyPlantsTitle": "Located plants",
"noPlants": "No geolocated plant yet. Scan with location enabled.",
"filter": {
"all": "All",
"diseases": "Diseases",
"guides": "Guides",
"plants": "My plants"
},
"section": {
"diseases": "Diseases",
"guides": "Practical guides",
"plants": "My plants"
},
"tag": {
"disease": "Disease",
"guide": "Guide",
"plant": "Plant"
}
},
"home": {
"greeting": "Hello, Winemaker!",
"scanButton": "Scan a vine",

View file

@ -22,6 +22,33 @@
"spreadMethod": "Propagation",
"timeline": "Période d'activité"
},
"search": {
"placeholder": "Que cherchez-vous ?",
"placeholderMap": "Rechercher une de mes plantes...",
"recentTitle": "Recherches récentes",
"clearAll": "Effacer tout",
"noRecent": "Aucune recherche récente. Tapez pour commencer.",
"resultsTitle": "Résultats",
"noResults": "Aucun résultat trouvé.",
"nearbyPlantsTitle": "Plantes localisées",
"noPlants": "Aucune plante géolocalisée. Scannez avec la position activée.",
"filter": {
"all": "Tout",
"diseases": "Maladies",
"guides": "Guides",
"plants": "Mes plantes"
},
"section": {
"diseases": "Maladies",
"guides": "Guides pratiques",
"plants": "Mes plantes"
},
"tag": {
"disease": "Maladie",
"guide": "Guide",
"plant": "Plante"
}
},
"home": {
"greeting": "Bonjour, Vigneron !",
"scanButton": "Scanner une vigne",

View file

@ -3,6 +3,7 @@ import { NavigationContainer } from '@react-navigation/native';
import SplashScreen from '@/screens/SplashScreen';
import ResultScreen from '@/screens/ResultScreen';
import SearchScreen from '@/screens/SearchScreen';
// import NotificationsScreen from '@/screens/NotificationsScreen'; // TODO: réactiver quand la page Notifications sera de retour
import ProfileScreen from '@/screens/ProfileScreen';
import SettingsScreen from '@/screens/SettingsScreen';
@ -35,6 +36,15 @@ export default function RootNavigator() {
component={ResultScreen}
options={{ animation: 'slide_from_bottom', presentation: 'modal' }}
/>
<Stack.Screen
name="Search"
component={SearchScreen}
options={({ route }) => ({
presentation: 'modal',
animation: route.params?.fromMap ? 'fade' : 'fade_from_bottom',
animationDuration: 250,
})}
/>
{/* <Stack.Screen name="Notifications" component={NotificationsScreen} /> */}
<Stack.Screen name="Profile" component={ProfileScreen} />
<Stack.Screen name="Settings" component={SettingsScreen} />

View file

@ -16,6 +16,7 @@ const linking: LinkingOptions<RootStackParamList> = {
},
},
Result: 'result',
Search: 'search',
// Notifications: 'notifications', // TODO: réactiver quand la page Notifications sera de retour
Profile: 'profile',
Settings: 'settings',

View file

@ -1,8 +1,13 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, 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 {
useFocusEffect,
useNavigation,
useRoute,
} from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { RouteProp } from "@react-navigation/native";
import BottomSheet from "@gorhom/bottom-sheet";
import { LinearGradient } from "expo-linear-gradient";
import { toast } from "sonner-native";
@ -20,9 +25,10 @@ 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";
import type { BottomTabParamList, RootStackParamList } from "@/types/navigation";
type Nav = NativeStackNavigationProp<RootStackParamList>;
type MapRoute = RouteProp<BottomTabParamList, "Map">;
const DEFAULT_REGION: MapRegion = {
latitude: 44.8378,
@ -68,6 +74,7 @@ export default function MapScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const navigation = useNavigation<Nav>();
const route = useRoute<MapRoute>();
const { history, renameScan, reload } = useHistory();
const { requestAndGetLocation } = useScanLocation();
const mapRef = useRef<VineyardMapHandle>(null);
@ -87,6 +94,51 @@ export default function MapScreen() {
const locatedScans = useMemo(() => history.filter(hasLocation), [history]);
const initialRegion = useMemo(() => computeInitialRegion(history), [history]);
// React to focusScanId param (from SearchScreen) → animate + zoom + preview
const focusScanId = route.params?.focusScanId;
useEffect(() => {
if (!focusScanId) return;
const target = locatedScans.find((s) => s.id === focusScanId);
if (!target) return;
// Zoom progressif : étape large → étape proche pour effet de glissement + zoom
// Décale la caméra vers le sud (lat - delta * 0.18) pour que la plante
// apparaisse au-dessus du bottom sheet (qui occupe ~20% bas)
const FAR_DELTA = 0.08;
const CLOSE_DELTA = 0.012;
mapRef.current?.animateToRegion(
{
latitude: target.latitude - FAR_DELTA * 0.18,
longitude: target.longitude,
latitudeDelta: FAR_DELTA,
longitudeDelta: FAR_DELTA,
},
450,
);
const t1 = setTimeout(() => {
mapRef.current?.animateToRegion(
{
latitude: target.latitude - CLOSE_DELTA * 0.18,
longitude: target.longitude,
latitudeDelta: CLOSE_DELTA,
longitudeDelta: CLOSE_DELTA,
},
500,
);
}, 480);
const t2 = setTimeout(() => {
setPreviewScan(target);
sheetRef.current?.snapToIndex(0);
}, 1000);
// Reset le param pour que ça ne re-trigger pas (ex. retour sur Map)
navigation.setParams({ focusScanId: undefined } as never);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [focusScanId, locatedScans, navigation]);
function handleScanPress(scan: ScanRecord) {
// 2nd click on the same scan in preview → open detail
if (previewScan?.id === scan.id) {
@ -96,12 +148,14 @@ export default function MapScreen() {
}
// 1st click (or click on a different scan) → preview mode
// Décalage caméra vers le sud pour ne pas être masqué par le sheet
if (hasLocation(scan)) {
const delta = 0.04;
mapRef.current?.animateToRegion({
latitude: scan.latitude,
latitude: scan.latitude - delta * 0.18,
longitude: scan.longitude,
latitudeDelta: 0.04,
longitudeDelta: 0.04,
latitudeDelta: delta,
longitudeDelta: delta,
});
}
setActiveFilter(null);

View file

@ -18,7 +18,6 @@ import { DateGroupAccordion } from '@/components/my-plants/DateGroupAccordion';
import { HeaderActionButtons } from '@/components/shared/HeaderActionButtons';
import SearchBar from '@/components/shared/SearchBar';
import { useHistory } from '@/hooks/useHistory';
import { getCepageById } from '@/utils/cepages';
import { groupScansByDate } from '@/utils/dateGrouping';
import type { DateGroupKey, DateGroup } from '@/utils/dateGrouping';
import { colors } from '@/theme/colors';
@ -38,7 +37,6 @@ export default function MyPlantsScreen() {
const insets = useSafeAreaInsets();
const { history, isLoading, deleteScan, toggleFavorite, reload } = useHistory();
const [searchQuery, setSearchQuery] = useState('');
const [openGroups, setOpenGroups] = useState<Set<DateGroupKey>>(
new Set(DEFAULT_OPEN),
);
@ -51,34 +49,8 @@ export default function MyPlantsScreen() {
}, [reload]),
);
// Filter scans by search query
const filteredScans = useMemo(() => {
if (!searchQuery.trim()) return history;
const q = searchQuery.toLowerCase().trim();
return history.filter((scan) => {
// Search by cepage name
if (scan.detection.cepageId) {
const c = getCepageById(scan.detection.cepageId);
if (
c?.name.fr.toLowerCase().includes(q) ||
c?.name.en.toLowerCase().includes(q)
) {
return true;
}
}
// Search by result label
const resultLabel =
scan.detection.result === 'vine'
? t('result.vineDetected')
: scan.detection.result === 'uncertain'
? t('result.uncertain')
: t('result.notVine');
return resultLabel.toLowerCase().includes(q);
});
}, [history, searchQuery, t]);
// Group filtered scans by date
const groups = useMemo(() => groupScansByDate(filteredScans), [filteredScans]);
// Group scans by date
const groups = useMemo(() => groupScansByDate(history), [history]);
function toggleGroup(key: DateGroupKey) {
setOpenGroups((prev) => {
@ -139,12 +111,11 @@ export default function MyPlantsScreen() {
<HeaderActionButtons />
</View>
{/* Search bar */}
{/* Search bar (trigger global SearchScreen) */}
<View style={styles.searchContainer}>
<SearchBar
placeholder={t('myPlants.searchPlaceholder')}
value={searchQuery}
onChangeText={setSearchQuery}
onTriggerPress={() => navigation.navigate('Search')}
/>
</View>

View file

@ -0,0 +1,648 @@
import { useEffect, useMemo, useState } from "react";
import { View, TextInput, Pressable, ScrollView, Keyboard } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation, useRoute } from "@react-navigation/native";
import type {
NativeStackNavigationProp,
NativeStackScreenProps,
} from "@react-navigation/native-stack";
import { useTranslation } from "react-i18next";
import { Image } from "expo-image";
import {
Search,
X,
Clock,
Leaf,
BookOpen,
MapPin,
AlertTriangle,
ChevronDown,
} from "lucide-react-native";
import { Text } from "@/components/ui/text";
import { useRecentSearches } from "@/hooks/useRecentSearches";
import { useDiseases } from "@/hooks/useDiseases";
import { useGuides } from "@/hooks/useGuides";
import { useHistory } from "@/hooks/useHistory";
import { useScanLocation } from "@/hooks/useScanLocation";
import { getCepageById } from "@/utils/cepages";
import { haversineDistance, formatDistance } from "@/utils/distance";
import { colors } from "@/theme/colors";
import { getScanStatus } from "@/types/detection";
import type { ScanRecord, ScanStatus } from "@/types/detection";
import type { RootStackParamList } from "@/types/navigation";
type Nav = NativeStackNavigationProp<RootStackParamList>;
type Props = NativeStackScreenProps<RootStackParamList, "Search">;
type Category = "all" | "disease" | "guide" | "plant";
const STATUS_COLOR: Record<ScanStatus, string> = {
healthy: colors.primary[700],
infected: "#E63946",
uncertain: "#F4A261",
};
const FALLBACK_IMG = require("../../assets/logo.png");
function hasLocation(
scan: ScanRecord,
): scan is ScanRecord & { latitude: number; longitude: number } {
return typeof scan.latitude === "number" && typeof scan.longitude === "number";
}
function getScanName(scan: ScanRecord, t: (k: string) => string): string {
if (scan.customName?.trim()) return scan.customName.trim();
if (scan.detection.cepageId) {
const c = getCepageById(scan.detection.cepageId);
if (c) return c.name.fr;
}
if (scan.detection.result === "vine") return t("result.vineDetected");
if (scan.detection.result === "uncertain") return t("result.uncertain");
return t("result.notVine");
}
// === Tag (badge) per category ===
function CategoryTag({ kind }: { kind: "disease" | "guide" | "plant" }) {
const { t } = useTranslation();
const styles =
kind === "disease"
? { bg: "bg-[#FBE9E7]", text: "text-[#D32F2F]" }
: kind === "guide"
? { bg: "bg-[#E3F2FD]", text: "text-[#1565C0]" }
: { bg: "bg-[#E8F5E9]", text: "text-[#2D6A4F]" };
const labelKey =
kind === "disease"
? "search.tag.disease"
: kind === "guide"
? "search.tag.guide"
: "search.tag.plant";
return (
<View className={`px-2 py-0.5 rounded-full ${styles.bg}`}>
<Text
className={`text-[10px] font-bold uppercase tracking-[0.5px] ${styles.text}`}
>
{t(labelKey)}
</Text>
</View>
);
}
export default function SearchScreen() {
const { t, i18n } = useTranslation();
const navigation = useNavigation<Nav>();
const route = useRoute<Props["route"]>();
const fromMap = route.params?.fromMap === true;
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<Category>("all");
const [closedSections, setClosedSections] = useState<
Set<"disease" | "guide" | "plant">
>(new Set());
const { recents, addRecent, removeRecent, clearRecents } =
useRecentSearches();
const { data: diseases } = useDiseases();
const { data: guides } = useGuides();
const { history } = useHistory();
const { requestAndGetLocation } = useScanLocation();
const [userCoords, setUserCoords] = useState<{
latitude: number;
longitude: number;
} | null>(null);
// Fetch user location once when on map mode
useEffect(() => {
if (!fromMap) return;
let alive = true;
(async () => {
const coords = await requestAndGetLocation();
if (alive && coords) setUserCoords(coords);
})();
return () => {
alive = false;
};
}, [fromMap, requestAndGetLocation]);
const trimmed = query.trim().toLowerCase();
const isSearching = trimmed.length > 0;
// PLANTS LIST (map mode only) : located plants with distance
const mapPlantHits = useMemo(() => {
if (!fromMap) return [];
const located = history.filter(hasLocation);
const filtered = isSearching
? located.filter((s) =>
getScanName(s, t).toLowerCase().includes(trimmed),
)
: located;
return filtered
.map((scan) => ({
scan,
distance: userCoords
? haversineDistance(userCoords, {
latitude: scan.latitude,
longitude: scan.longitude,
})
: null,
}))
.sort((a, b) => {
if (a.distance !== null && b.distance !== null)
return a.distance - b.distance;
if (a.distance !== null) return -1;
if (b.distance !== null) return 1;
return (
new Date(b.scan.createdAt).getTime() -
new Date(a.scan.createdAt).getTime()
);
});
}, [fromMap, history, isSearching, trimmed, userCoords, t]);
// GLOBAL HITS (3 categories) : diseases + guides + plants
const diseaseHits = useMemo(() => {
if (fromMap || !isSearching) return [];
return diseases.filter((d) => {
const name = t(d.name).toLowerCase();
const desc = t(d.description).toLowerCase();
return name.includes(trimmed) || desc.includes(trimmed);
});
}, [fromMap, isSearching, diseases, trimmed, t]);
const guideHits = useMemo(() => {
if (fromMap || !isSearching) return [];
return guides.filter((g) => {
const title = t(g.title).toLowerCase();
const subtitle = t(g.subtitle).toLowerCase();
return title.includes(trimmed) || subtitle.includes(trimmed);
});
}, [fromMap, isSearching, guides, trimmed, t]);
const plantHits = useMemo(() => {
if (fromMap || !isSearching) return [];
return history.filter((scan) => {
const name = getScanName(scan, t).toLowerCase();
return name.includes(trimmed);
});
}, [fromMap, isSearching, history, trimmed, t]);
const totalHits = diseaseHits.length + guideHits.length + plantHits.length;
function handleSelectRecent(rec: string) {
setQuery(rec);
}
function handleSelectDisease(id: string) {
if (query.trim().length > 0) addRecent(query);
Keyboard.dismiss();
navigation.navigate("DiseaseDetail", { diseaseId: id });
}
function handleSelectGuide(id: string) {
if (query.trim().length > 0) addRecent(query);
Keyboard.dismiss();
navigation.navigate("GuideDetail", { guideId: id });
}
function handleSelectPlant(scan: ScanRecord) {
if (query.trim().length > 0) addRecent(query);
Keyboard.dismiss();
if (hasLocation(scan)) {
navigation.navigate("Main", {
screen: "Map",
params: { focusScanId: scan.id },
});
} else {
navigation.navigate("ScanDetail", { scanId: scan.id });
}
}
function handleSubmit() {
if (trimmed.length === 0) return;
addRecent(query);
Keyboard.dismiss();
}
function toggleSection(kind: "disease" | "guide" | "plant") {
setClosedSections((prev) => {
const next = new Set(prev);
if (next.has(kind)) next.delete(kind);
else next.add(kind);
return next;
});
}
const showSection = (kind: Category) =>
filter === "all" || filter === kind;
return (
<SafeAreaView className="flex-1 bg-white" edges={["top"]}>
{/* Header : SearchBar + Cancel */}
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-[#F0F0F0]">
<View className="flex-1 flex-row items-center bg-[#F5F7F9] rounded-full px-4 h-12 border border-[#EAECEF]">
<Search size={18} color={colors.neutral[400]} />
<TextInput
className="flex-1 ml-2.5 text-[15px] font-medium text-[#1A1A1A]"
style={{
paddingVertical: 0,
textAlignVertical: "center",
includeFontPadding: false,
}}
value={query}
onChangeText={setQuery}
onSubmitEditing={handleSubmit}
placeholder={
fromMap ? t("search.placeholderMap") : t("search.placeholder")
}
placeholderTextColor={colors.neutral[400]}
selectionColor={colors.primary[500]}
autoFocus
autoCorrect={false}
returnKeyType="search"
/>
{query.length > 0 && (
<Pressable onPress={() => setQuery("")} hitSlop={8} className="ml-2">
<X size={16} color={colors.neutral[400]} />
</Pressable>
)}
</View>
<Pressable onPress={() => navigation.goBack()} hitSlop={8}>
<Text className="text-[15px] font-semibold text-[#2D6A4F]">
{t("common.cancel")}
</Text>
</Pressable>
</View>
{/* Filter chips (global mode + searching) */}
{!fromMap && isSearching && (
<View className="border-b border-[#F0F0F0]">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16, gap: 8, paddingVertical: 12 }}
>
<FilterChip
label={t("search.filter.all")}
count={totalHits}
active={filter === "all"}
onPress={() => setFilter("all")}
/>
<FilterChip
label={t("search.filter.diseases")}
count={diseaseHits.length}
active={filter === "disease"}
onPress={() => setFilter("disease")}
/>
<FilterChip
label={t("search.filter.guides")}
count={guideHits.length}
active={filter === "guide"}
onPress={() => setFilter("guide")}
/>
<FilterChip
label={t("search.filter.plants")}
count={plantHits.length}
active={filter === "plant"}
onPress={() => setFilter("plant")}
/>
</ScrollView>
</View>
)}
{/* Body */}
{fromMap ? (
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingBottom: 32 }}
>
{mapPlantHits.length > 0 && (
<Text className="px-5 pt-5 pb-2 text-[12px] font-bold uppercase tracking-[1px] text-[#8E8E93]">
{isSearching
? t("search.resultsTitle")
: t("search.nearbyPlantsTitle")}
</Text>
)}
{mapPlantHits.length === 0 ? (
<View className="items-center justify-center px-8 pt-20">
<Text className="text-[15px] text-[#8E8E93] text-center">
{isSearching ? t("search.noResults") : t("search.noPlants")}
</Text>
</View>
) : (
mapPlantHits.map(({ scan, distance }) => {
const status = getScanStatus(scan);
const hasImg = !!scan.detection.imageUri;
return (
<Pressable
key={scan.id}
onPress={() => handleSelectPlant(scan)}
className="flex-row items-center gap-3 px-5 py-3 active:bg-[#F8F9FB]"
>
<View className="w-12 h-12 rounded-2xl bg-[#F8F9FB] overflow-hidden items-center justify-center">
<Image
source={
hasImg ? { uri: scan.detection.imageUri } : FALLBACK_IMG
}
style={{ width: 48, height: 48 }}
contentFit={hasImg ? "cover" : "contain"}
transition={200}
/>
</View>
<View className="flex-1">
<View className="flex-row items-center gap-2">
<Text
className="flex-1 text-[15px] font-semibold text-[#1A1A1A]"
numberOfLines={1}
>
{getScanName(scan, t)}
</Text>
<CategoryTag kind="plant" />
</View>
<View className="flex-row items-center gap-1.5 mt-0.5">
<View
className="w-2 h-2 rounded-full"
style={{ backgroundColor: STATUS_COLOR[status] }}
/>
<Text className="text-[12px] text-[#8E8E93]">
{t(`myPlants.status.${status}`)}
</Text>
</View>
</View>
{distance !== null && (
<View className="flex-row items-center gap-1">
<MapPin size={14} color={colors.neutral[500]} />
<Text className="text-[13px] font-semibold text-[#6B6B6B]">
{formatDistance(distance, i18n.language)}
</Text>
</View>
)}
</Pressable>
);
})
)}
</ScrollView>
) : isSearching ? (
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingBottom: 32 }}
>
{totalHits === 0 && (
<View className="items-center justify-center px-8 pt-20">
<Text className="text-[15px] text-[#8E8E93] text-center">
{t("search.noResults")}
</Text>
</View>
)}
{showSection("disease") && diseaseHits.length > 0 && (
<Section
title={t("search.section.diseases")}
count={diseaseHits.length}
isClosed={closedSections.has("disease")}
onToggle={() => toggleSection("disease")}
>
{diseaseHits.map((d) => (
<ResultRow
key={d.id}
onPress={() => handleSelectDisease(d.id)}
iconBg="bg-[#FBE9E7]"
icon={
<AlertTriangle
size={18}
color="#D32F2F"
strokeWidth={2.2}
/>
}
title={t(d.name)}
subtitle={t(`diseases.types.${d.type}`)}
tag={<CategoryTag kind="disease" />}
/>
))}
</Section>
)}
{showSection("guide") && guideHits.length > 0 && (
<Section
title={t("search.section.guides")}
count={guideHits.length}
isClosed={closedSections.has("guide")}
onToggle={() => toggleSection("guide")}
>
{guideHits.map((g) => (
<ResultRow
key={g.id}
onPress={() => handleSelectGuide(g.id)}
iconBg="bg-[#E3F2FD]"
icon={
<BookOpen size={18} color="#1565C0" strokeWidth={2.2} />
}
title={t(g.title)}
subtitle={t(g.subtitle)}
tag={<CategoryTag kind="guide" />}
/>
))}
</Section>
)}
{showSection("plant") && plantHits.length > 0 && (
<Section
title={t("search.section.plants")}
count={plantHits.length}
isClosed={closedSections.has("plant")}
onToggle={() => toggleSection("plant")}
>
{plantHits.map((scan) => {
const status = getScanStatus(scan);
return (
<ResultRow
key={scan.id}
onPress={() => handleSelectPlant(scan)}
iconBg="bg-[#E8F5E9]"
icon={
<Leaf
size={18}
color={colors.primary[700]}
strokeWidth={2.2}
/>
}
title={getScanName(scan, t)}
subtitle={t(`myPlants.status.${status}`)}
tag={<CategoryTag kind="plant" />}
/>
);
})}
</Section>
)}
</ScrollView>
) : (
// No query : recents
<View className="flex-1">
<View className="flex-row items-center justify-between px-5 pt-5 pb-2">
<Text className="text-[12px] font-bold uppercase tracking-[1px] text-[#8E8E93]">
{t("search.recentTitle")}
</Text>
{recents.length > 0 && (
<Pressable onPress={clearRecents} hitSlop={6}>
<Text className="text-[13px] font-semibold text-[#2D6A4F]">
{t("search.clearAll")}
</Text>
</Pressable>
)}
</View>
{recents.length === 0 ? (
<View className="flex-1 items-center justify-center px-8 pt-10">
<Text className="text-[15px] text-[#8E8E93] text-center">
{t("search.noRecent")}
</Text>
</View>
) : (
<ScrollView keyboardShouldPersistTaps="handled">
{recents.map((item) => (
<Pressable
key={item}
onPress={() => handleSelectRecent(item)}
className="flex-row items-center gap-3 px-5 py-3 active:bg-[#F8F9FB]"
>
<Clock size={18} color={colors.neutral[500]} />
<Text
className="flex-1 text-[15px] text-[#1A1A1A]"
numberOfLines={1}
>
{item}
</Text>
<Pressable
onPress={() => removeRecent(item)}
hitSlop={10}
className="w-8 h-8 items-center justify-center"
>
<X size={16} color={colors.neutral[400]} />
</Pressable>
</Pressable>
))}
</ScrollView>
)}
</View>
)}
</SafeAreaView>
);
}
// === Subcomponents ===
interface FilterChipProps {
label: string;
count: number;
active: boolean;
onPress: () => void;
}
function FilterChip({ label, count, active, onPress }: FilterChipProps) {
return (
<Pressable
onPress={onPress}
className={
active
? "flex-row items-center px-4 py-2 rounded-full bg-[#2D6A4F]"
: "flex-row items-center px-4 py-2 rounded-full bg-white border border-[#E2E4E7]"
}
>
<Text
className={
active
? "text-[13px] font-semibold text-white"
: "text-[13px] font-medium text-[#2D2D2D]"
}
>
{label}
</Text>
{count > 0 && (
<Text
className={
active
? "text-[12px] font-bold text-white ml-1.5"
: "text-[12px] font-bold text-[#8E8E93] ml-1.5"
}
>
{count}
</Text>
)}
</Pressable>
);
}
interface SectionProps {
title: string;
count: number;
isClosed: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function Section({ title, count, isClosed, onToggle, children }: SectionProps) {
return (
<View className="mt-2">
<Pressable
onPress={onToggle}
className="flex-row items-center justify-between px-5 py-3 active:bg-[#F8F9FB]"
>
<View className="flex-row items-center gap-2">
<Text className="text-[12px] font-bold uppercase tracking-[1px] text-[#8E8E93]">
{title}
</Text>
<Text className="text-[12px] font-bold text-[#8E8E93]">
({count})
</Text>
</View>
<View
style={{
transform: [{ rotate: isClosed ? "-90deg" : "0deg" }],
}}
>
<ChevronDown size={18} color={colors.neutral[500]} />
</View>
</Pressable>
{!isClosed && <View>{children}</View>}
</View>
);
}
interface ResultRowProps {
onPress: () => void;
iconBg: string;
icon: React.ReactNode;
title: string;
subtitle: string;
tag: React.ReactNode;
}
function ResultRow({
onPress,
iconBg,
icon,
title,
subtitle,
tag,
}: ResultRowProps) {
return (
<Pressable
onPress={onPress}
className="flex-row items-center gap-3 px-5 py-3 active:bg-[#F8F9FB]"
>
<View
className={`w-10 h-10 rounded-full items-center justify-center ${iconBg}`}
>
{icon}
</View>
<View className="flex-1">
<View className="flex-row items-center gap-2">
<Text
className="flex-1 text-[15px] font-semibold text-[#1A1A1A]"
numberOfLines={1}
>
{title}
</Text>
{tag}
</View>
<Text className="text-[13px] text-[#8E8E93] mt-0.5" numberOfLines={1}>
{subtitle}
</Text>
</View>
</Pressable>
);
}

View file

@ -6,13 +6,14 @@ export type BottomTabParamList = {
Guides: undefined;
Scanner: undefined;
MyPlants: undefined;
Map: undefined;
Map: { focusScanId?: string } | undefined;
};
export type RootStackParamList = {
Splash: undefined;
Main: NavigatorScreenParams<BottomTabParamList>;
Result: { detection: Detection };
Search: { fromMap?: boolean } | undefined;
// Notifications: undefined; // TODO: réactiver quand la page Notifications sera de retour
Profile: undefined;
Settings: undefined;

View file

@ -0,0 +1,34 @@
/**
* Haversine distance entre deux points GPS, en mètres.
*/
export function haversineDistance(
a: { latitude: number; longitude: number },
b: { latitude: number; longitude: number },
): number {
const R = 6371000; // Earth radius in meters
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(b.latitude - a.latitude);
const dLng = toRad(b.longitude - a.longitude);
const lat1 = toRad(a.latitude);
const lat2 = toRad(b.latitude);
const x =
Math.sin(dLat / 2) ** 2 +
Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2);
const c = 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x));
return R * c;
}
/**
* Format une distance en mètres pour l'UI (ex. "1.2 km", "350 m").
*/
export function formatDistance(meters: number, locale: string = "fr"): string {
if (meters < 1000) {
return `${Math.round(meters)} m`;
}
const km = meters / 1000;
const formatted =
km >= 100 ? Math.round(km).toString() : km.toFixed(1).replace(".", locale === "fr" ? "," : ".");
return `${formatted} km`;
}