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:
parent
07f25769e3
commit
25b56092d5
|
|
@ -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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import SearchBar from "@/components/shared/SearchBar";
|
import SearchBar from "@/components/shared/SearchBar";
|
||||||
|
import type { RootStackParamList } from "@/types/navigation";
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
export default function SearchSection() {
|
export default function SearchSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View className="px-5 pt-1 pb-4">
|
||||||
<SearchBar placeholder={t("home.searchPlaceholder") ?? "Rechercher..."} />
|
<SearchBar
|
||||||
|
placeholder={t("home.searchPlaceholder") ?? "Rechercher..."}
|
||||||
|
onTriggerPress={() => navigation.navigate("Search")}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingBottom: 16,
|
|
||||||
paddingTop: 4,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { View, ScrollView, Pressable, StyleSheet } from "react-native";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { MapPin } from "lucide-react-native";
|
import { MapPin } from "lucide-react-native";
|
||||||
|
|
||||||
|
|
@ -8,6 +9,9 @@ import SearchBar from "@/components/shared/SearchBar";
|
||||||
import { HeaderActionButtons } from "@/components/shared/HeaderActionButtons";
|
import { HeaderActionButtons } from "@/components/shared/HeaderActionButtons";
|
||||||
import { colors } from "@/theme/colors";
|
import { colors } from "@/theme/colors";
|
||||||
import { WINE_REGIONS } from "@/data/wineRegions";
|
import { WINE_REGIONS } from "@/data/wineRegions";
|
||||||
|
import type { RootStackParamList } from "@/types/navigation";
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
export type MapFilterId = "myLocation" | string;
|
export type MapFilterId = "myLocation" | string;
|
||||||
|
|
||||||
|
|
@ -21,7 +25,7 @@ export function FloatingSearch({
|
||||||
onFilterPress,
|
onFilterPress,
|
||||||
}: FloatingSearchProps) {
|
}: FloatingSearchProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState("");
|
const navigation = useNavigation<Nav>();
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
{
|
{
|
||||||
|
|
@ -42,8 +46,9 @@ export function FloatingSearch({
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<SearchBar
|
<SearchBar
|
||||||
placeholder={t("map.searchPlaceholder")}
|
placeholder={t("map.searchPlaceholder")}
|
||||||
value={query}
|
onTriggerPress={() =>
|
||||||
onChangeText={setQuery}
|
navigation.navigate("Search", { fromMap: true })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<HeaderActionButtons />
|
<HeaderActionButtons />
|
||||||
|
|
|
||||||
|
|
@ -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 { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
import { colors } from "@/theme/colors";
|
import { colors } from "@/theme/colors";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
|
|
@ -8,7 +9,9 @@ interface SearchBarProps {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChangeText?: (text: string) => void;
|
onChangeText?: (text: string) => void;
|
||||||
onFilterPress?: () => void;
|
onFilterPress?: () => void;
|
||||||
|
onTriggerPress?: () => void;
|
||||||
showFilter?: boolean;
|
showFilter?: boolean;
|
||||||
|
autoFocus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchBar({
|
export default function SearchBar({
|
||||||
|
|
@ -16,82 +19,76 @@ export default function SearchBar({
|
||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
onFilterPress,
|
onFilterPress,
|
||||||
showFilter = true,
|
onTriggerPress,
|
||||||
|
showFilter = false,
|
||||||
|
autoFocus = false,
|
||||||
}: SearchBarProps) {
|
}: SearchBarProps) {
|
||||||
return (
|
const triggerMode = !!onTriggerPress;
|
||||||
<View style={styles.searchWrapper}>
|
const wrapperClass =
|
||||||
<Ionicons
|
"flex-row items-center bg-white rounded-full px-4 h-[52px] border border-[#EAECEF]";
|
||||||
name="search"
|
|
||||||
size={20}
|
|
||||||
color={colors.neutral[400]}
|
|
||||||
style={styles.searchIcon}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
const content = (
|
||||||
style={styles.input}
|
<>
|
||||||
value={value}
|
<Ionicons name="search" size={20} color={colors.neutral[400]} />
|
||||||
multiline={false}
|
|
||||||
numberOfLines={1}
|
{triggerMode ? (
|
||||||
scrollEnabled={false}
|
<Text
|
||||||
onChangeText={onChangeText}
|
className="flex-1 ml-2.5 text-[15px] font-medium text-[#9CA3AF]"
|
||||||
placeholder={placeholder ?? "Rechercher..."}
|
numberOfLines={1}
|
||||||
placeholderTextColor={colors.neutral[400]}
|
>
|
||||||
selectionColor={colors.primary[500]}
|
{placeholder ?? "Rechercher..."}
|
||||||
autoCorrect={false}
|
</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 && (
|
{showFilter && (
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
onPress={onFilterPress}
|
onPress={(e) => {
|
||||||
style={styles.filterButton}
|
e.stopPropagation?.();
|
||||||
activeOpacity={0.7}
|
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
|
<Ionicons
|
||||||
name="options-outline"
|
name="options-outline"
|
||||||
size={18}
|
size={18}
|
||||||
color={colors.primary[600]}
|
color={colors.primary[600]}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
if (triggerMode) {
|
||||||
searchWrapper: {
|
return (
|
||||||
flexDirection: "row",
|
<Pressable
|
||||||
alignItems: "center",
|
onPress={onTriggerPress}
|
||||||
backgroundColor: "#fff",
|
className={`${wrapperClass} active:opacity-90`}
|
||||||
borderRadius: 100,
|
>
|
||||||
paddingHorizontal: 16,
|
{content}
|
||||||
height: 52,
|
</Pressable>
|
||||||
borderWidth: 1,
|
);
|
||||||
borderColor: "#EAECEF",
|
}
|
||||||
},
|
|
||||||
searchIcon: {
|
return <View className={wrapperClass}>{content}</View>;
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
||||||
63
VinEye/src/hooks/useRecentSearches.ts
Normal file
63
VinEye/src/hooks/useRecentSearches.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,33 @@
|
||||||
"spreadMethod": "Spread method",
|
"spreadMethod": "Spread method",
|
||||||
"timeline": "Active period"
|
"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": {
|
"home": {
|
||||||
"greeting": "Hello, Winemaker!",
|
"greeting": "Hello, Winemaker!",
|
||||||
"scanButton": "Scan a vine",
|
"scanButton": "Scan a vine",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,33 @@
|
||||||
"spreadMethod": "Propagation",
|
"spreadMethod": "Propagation",
|
||||||
"timeline": "Période d'activité"
|
"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": {
|
"home": {
|
||||||
"greeting": "Bonjour, Vigneron !",
|
"greeting": "Bonjour, Vigneron !",
|
||||||
"scanButton": "Scanner une vigne",
|
"scanButton": "Scanner une vigne",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
|
||||||
import SplashScreen from '@/screens/SplashScreen';
|
import SplashScreen from '@/screens/SplashScreen';
|
||||||
import ResultScreen from '@/screens/ResultScreen';
|
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 NotificationsScreen from '@/screens/NotificationsScreen'; // TODO: réactiver quand la page Notifications sera de retour
|
||||||
import ProfileScreen from '@/screens/ProfileScreen';
|
import ProfileScreen from '@/screens/ProfileScreen';
|
||||||
import SettingsScreen from '@/screens/SettingsScreen';
|
import SettingsScreen from '@/screens/SettingsScreen';
|
||||||
|
|
@ -35,6 +36,15 @@ export default function RootNavigator() {
|
||||||
component={ResultScreen}
|
component={ResultScreen}
|
||||||
options={{ animation: 'slide_from_bottom', presentation: 'modal' }}
|
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="Notifications" component={NotificationsScreen} /> */}
|
||||||
<Stack.Screen name="Profile" component={ProfileScreen} />
|
<Stack.Screen name="Profile" component={ProfileScreen} />
|
||||||
<Stack.Screen name="Settings" component={SettingsScreen} />
|
<Stack.Screen name="Settings" component={SettingsScreen} />
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ const linking: LinkingOptions<RootStackParamList> = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Result: 'result',
|
Result: 'result',
|
||||||
|
Search: 'search',
|
||||||
// Notifications: 'notifications', // TODO: réactiver quand la page Notifications sera de retour
|
// Notifications: 'notifications', // TODO: réactiver quand la page Notifications sera de retour
|
||||||
Profile: 'profile',
|
Profile: 'profile',
|
||||||
Settings: 'settings',
|
Settings: 'settings',
|
||||||
|
|
|
||||||
|
|
@ -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 { View, StyleSheet } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
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 { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import type { RouteProp } from "@react-navigation/native";
|
||||||
import BottomSheet from "@gorhom/bottom-sheet";
|
import BottomSheet from "@gorhom/bottom-sheet";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
|
@ -20,9 +25,10 @@ import { useHistory } from "@/hooks/useHistory";
|
||||||
import { useScanLocation } from "@/hooks/useScanLocation";
|
import { useScanLocation } from "@/hooks/useScanLocation";
|
||||||
import { getRegionById } from "@/data/wineRegions";
|
import { getRegionById } from "@/data/wineRegions";
|
||||||
import type { ScanRecord } from "@/types/detection";
|
import type { ScanRecord } from "@/types/detection";
|
||||||
import type { RootStackParamList } from "@/types/navigation";
|
import type { BottomTabParamList, RootStackParamList } from "@/types/navigation";
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
type MapRoute = RouteProp<BottomTabParamList, "Map">;
|
||||||
|
|
||||||
const DEFAULT_REGION: MapRegion = {
|
const DEFAULT_REGION: MapRegion = {
|
||||||
latitude: 44.8378,
|
latitude: 44.8378,
|
||||||
|
|
@ -68,6 +74,7 @@ export default function MapScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
|
const route = useRoute<MapRoute>();
|
||||||
const { history, renameScan, reload } = useHistory();
|
const { history, renameScan, reload } = useHistory();
|
||||||
const { requestAndGetLocation } = useScanLocation();
|
const { requestAndGetLocation } = useScanLocation();
|
||||||
const mapRef = useRef<VineyardMapHandle>(null);
|
const mapRef = useRef<VineyardMapHandle>(null);
|
||||||
|
|
@ -87,6 +94,51 @@ export default function MapScreen() {
|
||||||
const locatedScans = useMemo(() => history.filter(hasLocation), [history]);
|
const locatedScans = useMemo(() => history.filter(hasLocation), [history]);
|
||||||
const initialRegion = useMemo(() => computeInitialRegion(history), [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) {
|
function handleScanPress(scan: ScanRecord) {
|
||||||
// 2nd click on the same scan in preview → open detail
|
// 2nd click on the same scan in preview → open detail
|
||||||
if (previewScan?.id === scan.id) {
|
if (previewScan?.id === scan.id) {
|
||||||
|
|
@ -96,12 +148,14 @@ export default function MapScreen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1st click (or click on a different scan) → preview mode
|
// 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)) {
|
if (hasLocation(scan)) {
|
||||||
|
const delta = 0.04;
|
||||||
mapRef.current?.animateToRegion({
|
mapRef.current?.animateToRegion({
|
||||||
latitude: scan.latitude,
|
latitude: scan.latitude - delta * 0.18,
|
||||||
longitude: scan.longitude,
|
longitude: scan.longitude,
|
||||||
latitudeDelta: 0.04,
|
latitudeDelta: delta,
|
||||||
longitudeDelta: 0.04,
|
longitudeDelta: delta,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setActiveFilter(null);
|
setActiveFilter(null);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import { DateGroupAccordion } from '@/components/my-plants/DateGroupAccordion';
|
||||||
import { HeaderActionButtons } from '@/components/shared/HeaderActionButtons';
|
import { HeaderActionButtons } from '@/components/shared/HeaderActionButtons';
|
||||||
import SearchBar from '@/components/shared/SearchBar';
|
import SearchBar from '@/components/shared/SearchBar';
|
||||||
import { useHistory } from '@/hooks/useHistory';
|
import { useHistory } from '@/hooks/useHistory';
|
||||||
import { getCepageById } from '@/utils/cepages';
|
|
||||||
import { groupScansByDate } from '@/utils/dateGrouping';
|
import { groupScansByDate } from '@/utils/dateGrouping';
|
||||||
import type { DateGroupKey, DateGroup } from '@/utils/dateGrouping';
|
import type { DateGroupKey, DateGroup } from '@/utils/dateGrouping';
|
||||||
import { colors } from '@/theme/colors';
|
import { colors } from '@/theme/colors';
|
||||||
|
|
@ -38,7 +37,6 @@ export default function MyPlantsScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { history, isLoading, deleteScan, toggleFavorite, reload } = useHistory();
|
const { history, isLoading, deleteScan, toggleFavorite, reload } = useHistory();
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [openGroups, setOpenGroups] = useState<Set<DateGroupKey>>(
|
const [openGroups, setOpenGroups] = useState<Set<DateGroupKey>>(
|
||||||
new Set(DEFAULT_OPEN),
|
new Set(DEFAULT_OPEN),
|
||||||
);
|
);
|
||||||
|
|
@ -51,34 +49,8 @@ export default function MyPlantsScreen() {
|
||||||
}, [reload]),
|
}, [reload]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter scans by search query
|
// Group scans by date
|
||||||
const filteredScans = useMemo(() => {
|
const groups = useMemo(() => groupScansByDate(history), [history]);
|
||||||
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]);
|
|
||||||
|
|
||||||
function toggleGroup(key: DateGroupKey) {
|
function toggleGroup(key: DateGroupKey) {
|
||||||
setOpenGroups((prev) => {
|
setOpenGroups((prev) => {
|
||||||
|
|
@ -139,12 +111,11 @@ export default function MyPlantsScreen() {
|
||||||
<HeaderActionButtons />
|
<HeaderActionButtons />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Search bar */}
|
{/* Search bar (trigger global SearchScreen) */}
|
||||||
<View style={styles.searchContainer}>
|
<View style={styles.searchContainer}>
|
||||||
<SearchBar
|
<SearchBar
|
||||||
placeholder={t('myPlants.searchPlaceholder')}
|
placeholder={t('myPlants.searchPlaceholder')}
|
||||||
value={searchQuery}
|
onTriggerPress={() => navigation.navigate('Search')}
|
||||||
onChangeText={setSearchQuery}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
|
||||||
648
VinEye/src/screens/SearchScreen.tsx
Normal file
648
VinEye/src/screens/SearchScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,13 +6,14 @@ export type BottomTabParamList = {
|
||||||
Guides: undefined;
|
Guides: undefined;
|
||||||
Scanner: undefined;
|
Scanner: undefined;
|
||||||
MyPlants: undefined;
|
MyPlants: undefined;
|
||||||
Map: undefined;
|
Map: { focusScanId?: string } | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
Splash: undefined;
|
Splash: undefined;
|
||||||
Main: NavigatorScreenParams<BottomTabParamList>;
|
Main: NavigatorScreenParams<BottomTabParamList>;
|
||||||
Result: { detection: Detection };
|
Result: { detection: Detection };
|
||||||
|
Search: { fromMap?: boolean } | undefined;
|
||||||
// Notifications: undefined; // TODO: réactiver quand la page Notifications sera de retour
|
// Notifications: undefined; // TODO: réactiver quand la page Notifications sera de retour
|
||||||
Profile: undefined;
|
Profile: undefined;
|
||||||
Settings: undefined;
|
Settings: undefined;
|
||||||
|
|
|
||||||
34
VinEye/src/utils/distance.ts
Normal file
34
VinEye/src/utils/distance.ts
Normal 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`;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue