Grapevine_Disease_Detection/VinEye/src/screens/MyPlantsScreen.tsx
Yanis 3781b1c0f4 feat(ui): shared SearchBar reused on Home, Map and MyPlants
Extrait la barre de recherche en composant partagé
(components/shared/SearchBar.tsx) avec props placeholder/value/onChangeText/
showFilter.

- Home (SearchSection) : utilise le composant partagé
- Map (FloatingSearch) : remplace l'input custom + ajuste les chips (border
  primary, font-size 15→12, MapPin couleur primary)
- MyPlants : remplace l'input custom + son bouton clear

Bonus : SearchBar gère proprement le clavier Android via numberOfLines={1},
multiline={false}, scrollEnabled={false}, lineHeight 20 + textAlignVertical
center + includeFontPadding false → le placeholder ne wrappe plus sur 2 lignes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:02:28 +02:00

265 lines
7.2 KiB
TypeScript

import { useState, useMemo, useCallback } from 'react';
import {
View,
FlatList,
TouchableOpacity,
Alert,
StyleSheet,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useTranslation } from 'react-i18next';
import { Image } from 'expo-image';
import { ScanLine } from 'lucide-react-native';
import { Text } from '@/components/ui/text';
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';
import type { RootStackParamList } from '@/types/navigation';
import type { ScanRecord } from '@/types/detection';
type Nav = NativeStackNavigationProp<RootStackParamList>;
const DEFAULT_OPEN: Set<DateGroupKey> = new Set(['today', 'yesterday', 'thisWeek']);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const EMPTY_IMAGE = require('../../assets/logo.png');
export default function MyPlantsScreen() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
const insets = useSafeAreaInsets();
const { history, isLoading, deleteScan, toggleFavorite, reload } = useHistory();
const [searchQuery, setSearchQuery] = useState('');
const [openGroups, setOpenGroups] = useState<Set<DateGroupKey>>(
new Set(DEFAULT_OPEN),
);
const [refreshing, setRefreshing] = useState(false);
// Reload scans when screen regains focus
useFocusEffect(
useCallback(() => {
reload();
}, [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]);
function toggleGroup(key: DateGroupKey) {
setOpenGroups((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
function handlePressScan(scan: ScanRecord) {
navigation.navigate('ScanDetail', { scanId: scan.id });
}
function handleDeleteScan(scanId: string) {
Alert.alert(
t('myPlants.actions.deleteConfirmTitle'),
t('myPlants.actions.deleteConfirmMessage'),
[
{ text: t('myPlants.actions.cancel'), style: 'cancel' },
{
text: t('myPlants.actions.delete'),
style: 'destructive',
onPress: () => deleteScan(scanId),
},
],
);
}
async function handleRefresh() {
setRefreshing(true);
await reload();
setRefreshing(false);
}
function renderGroup({ item }: { item: DateGroup }) {
return (
<DateGroupAccordion
groupKey={item.key}
label={item.label}
scans={item.scans}
isOpen={openGroups.has(item.key)}
onToggle={() => toggleGroup(item.key)}
onPressScan={handlePressScan}
onToggleFavorite={(id) => toggleFavorite(id)}
onDeleteScan={handleDeleteScan}
/>
);
}
const isEmpty = history.length === 0 && !isLoading;
return (
<View style={[styles.root, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>{t('myPlants.title')}</Text>
<HeaderActionButtons />
</View>
{/* Search bar */}
<View style={styles.searchContainer}>
<SearchBar
placeholder={t('myPlants.searchPlaceholder')}
value={searchQuery}
onChangeText={setSearchQuery}
/>
</View>
{/* Content */}
{isEmpty ? (
<View style={styles.emptyContainer}>
<View style={styles.emptyIconWrapper}>
<Image
source={EMPTY_IMAGE}
style={styles.emptyImage}
contentFit="contain"
/>
</View>
<Text style={styles.emptyTitle}>{t('myPlants.empty.title')}</Text>
<Text style={styles.emptySubtitle}>{t('myPlants.empty.subtitle')}</Text>
<TouchableOpacity
style={styles.emptyCta}
onPress={() => navigation.navigate('Main', { screen: 'Scanner' })}
activeOpacity={0.8}
>
<ScanLine size={18} color="#FFFFFF" />
<Text style={styles.emptyCtaText}>{t('myPlants.empty.cta')}</Text>
</TouchableOpacity>
</View>
) : (
<FlatList
data={groups}
keyExtractor={(item) => item.key}
renderItem={renderGroup}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshing={refreshing}
onRefresh={handleRefresh}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: '#F8F9FB',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 4,
gap: 12,
},
title: {
fontSize: 28,
fontWeight: '700',
color: '#1A1A1A',
},
// Search
searchContainer: {
paddingHorizontal: 20,
paddingVertical: 12,
},
// List
listContent: {
paddingBottom: 100,
},
// Empty state
emptyContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 40,
gap: 12,
},
emptyIconWrapper: {
width: 96,
height: 96,
borderRadius: 32,
// backgroundColor: '#F0F0F0',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8,
},
emptyImage: {
width: 96,
height: 96,
},
emptyTitle: {
fontSize: 18,
fontWeight: '600',
color: '#1A1A1A',
textAlign: 'center',
},
emptySubtitle: {
fontSize: 14,
fontWeight: '400',
color: '#8E8E93',
textAlign: 'center',
lineHeight: 20,
},
emptyCta: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: colors.primary[800],
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 100,
marginTop: 8,
},
emptyCtaText: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
},
});