Greenlens/app/lexicon.tsx

452 lines
15 KiB
TypeScript

import React, { useState } from 'react';
import {
View, Text, StyleSheet, TextInput, FlatList, TouchableOpacity, Platform, StatusBar, ScrollView, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useApp } from '../context/AppContext';
import { useColors } from '../constants/Colors';
import { PlantDatabaseService } from '../services/plantDatabaseService';
import { IdentificationResult } from '../types';
import { DatabaseEntry } from '../services/plantDatabaseService';
import { ResultCard } from '../components/ResultCard';
import { ThemeBackdrop } from '../components/ThemeBackdrop';
import { SafeImage } from '../components/SafeImage';
import { resolveImageUri } from '../utils/imageUri';
export default function LexiconScreen() {
const { isDarkMode, colorPalette, language, t, savePlant, getLexiconSearchHistory, saveLexiconSearchQuery, clearLexiconSearchHistory } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const insets = useSafeAreaInsets();
const router = useRouter();
const params = useLocalSearchParams();
const categoryIdParam = Array.isArray(params.categoryId) ? params.categoryId[0] : params.categoryId;
const categoryLabelParam = Array.isArray(params.categoryLabel) ? params.categoryLabel[0] : params.categoryLabel;
const decodeParam = (value?: string | string[]) => {
if (!value || typeof value !== 'string') return '';
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const initialCategoryId = typeof categoryIdParam === 'string' ? categoryIdParam : null;
const initialCategoryLabel = decodeParam(categoryLabelParam);
const topInsetFallback = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : 20;
const topInset = insets.top > 0 ? insets.top : topInsetFallback;
const [searchQuery, setSearchQuery] = useState(initialCategoryLabel);
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(initialCategoryId);
const [selectedItem, setSelectedItem] = useState<(IdentificationResult & { imageUri: string }) | null>(null);
const [isAiSearching, setIsAiSearching] = useState(false);
const [aiResults, setAiResults] = useState<DatabaseEntry[] | null>(null);
const [searchErrorMessage, setSearchErrorMessage] = useState<string | null>(null);
const [searchHistory, setSearchHistory] = useState<string[]>([]);
const detailParam = Array.isArray(params.detail) ? params.detail[0] : params.detail;
const openedWithDetail = Boolean(detailParam);
React.useEffect(() => {
if (detailParam) {
try {
const rawParam = detailParam;
const decoded = decodeURIComponent(rawParam as string);
const detail = JSON.parse(decoded);
setSelectedItem(detail);
} catch (e) {
try {
const fallbackRaw = detailParam;
const detail = JSON.parse(fallbackRaw as string);
setSelectedItem(detail);
} catch (fallbackError) {
console.error('Failed to parse plant detail', fallbackError);
}
}
}
}, [detailParam]);
React.useEffect(() => {
setActiveCategoryId(initialCategoryId);
setSearchQuery(initialCategoryLabel);
}, [initialCategoryId, initialCategoryLabel]);
React.useEffect(() => {
const loadHistory = async () => {
const history = getLexiconSearchHistory();
setSearchHistory(history);
};
loadHistory();
}, []);
const handleResultClose = () => {
if (openedWithDetail) {
router.back();
return;
}
setSelectedItem(null);
};
const normalizeText = (value: string): string => (
value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.replace(/\s+/g, ' ')
);
const effectiveSearchQuery = searchQuery;
const [lexiconPlants, setLexiconPlants] = useState<DatabaseEntry[]>([]);
React.useEffect(() => {
if (aiResults) {
setLexiconPlants(aiResults);
return;
}
let isCancelled = false;
PlantDatabaseService.searchPlants(effectiveSearchQuery, language, {
category: activeCategoryId,
limit: 500,
}).then(results => {
if (!isCancelled) setLexiconPlants(results);
}).catch(console.error);
return () => {
isCancelled = true;
};
}, [aiResults, effectiveSearchQuery, language, activeCategoryId]);
const handleAiSearch = async () => {
const query = searchQuery.trim();
if (!query) return;
setIsAiSearching(true);
setAiResults(null);
setSearchErrorMessage(null);
try {
const response = await PlantDatabaseService.semanticSearchDetailed(query, language);
if (response.status === 'success') {
setAiResults(response.results);
saveLexiconSearchQuery(query);
setSearchHistory(getLexiconSearchHistory());
} else if (response.status === 'insufficient_credits') {
setSearchErrorMessage((t as any).errorNoCredits || 'Nicht genügend Guthaben für KI-Suche.');
} else if (response.status === 'no_results') {
setSearchErrorMessage((t as any).noResultsFound || 'Keine Ergebnisse gefunden.');
setAiResults([]);
} else {
setSearchErrorMessage((t as any).errorTryAgain || 'Fehler bei der Suche. Bitte später erneut versuchen.');
}
} catch (error) {
console.error('AI Search failed', error);
setSearchErrorMessage((t as any).errorGeneral || 'Etwas ist schiefgelaufen.');
} finally {
setIsAiSearching(false);
}
};
const handleSearchSubmit = async () => {
const query = searchQuery.trim();
if (!query) return;
saveLexiconSearchQuery(query);
setSearchHistory(getLexiconSearchHistory());
};
const handleHistorySelect = (query: string) => {
setActiveCategoryId(null);
setSearchQuery(query);
};
const handleClearHistory = () => {
clearLexiconSearchHistory();
setSearchHistory([]);
};
const showSearchHistory = searchQuery.trim().length === 0 && !activeCategoryId && searchHistory.length > 0;
if (selectedItem) {
return (
<ResultCard
result={selectedItem}
imageUri={selectedItem.imageUri}
onSave={() => {
savePlant(selectedItem, resolveImageUri(selectedItem.imageUri));
router.back();
}}
onClose={handleResultClose}
t={t}
isDark={isDarkMode}
colorPalette={colorPalette}
/>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={['left', 'right', 'bottom']}>
<ThemeBackdrop colors={colors} />
{/* Header */}
<View
style={[
styles.header,
{
backgroundColor: colors.cardBg,
borderBottomColor: colors.cardBorder,
paddingTop: topInset + 8,
},
]}
>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="arrow-back" size={24} color={colors.textSecondary} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>{t.lexiconTitle}</Text>
</View>
{/* Search */}
<View style={{ paddingHorizontal: 20, paddingTop: 16 }}>
<View
style={[
styles.searchBar,
{ backgroundColor: colors.cardBg, borderColor: colors.inputBorder, shadowColor: colors.cardShadow },
]}
>
<Ionicons name="search" size={20} color={colors.textMuted} />
<TextInput
style={[styles.searchInput, { color: colors.text }]}
placeholder={t.lexiconSearchPlaceholder}
placeholderTextColor={colors.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
onSubmitEditing={handleSearchSubmit}
returnKeyType="search"
/>
{(searchQuery || activeCategoryId) ? (
<TouchableOpacity
onPress={() => {
setSearchQuery('');
setActiveCategoryId(null);
setAiResults(null);
}}
hitSlop={8}
>
<Ionicons name="close" size={18} color={colors.textMuted} />
</TouchableOpacity>
) : null}
</View>
</View>
{/* AI Search Trigger block removed */}
{searchErrorMessage && (
<View style={{ paddingHorizontal: 20, paddingTop: 12 }}>
<View style={[styles.errorBox, { backgroundColor: colors.danger + '20' }]}>
<Ionicons name="alert-circle" size={18} color={colors.danger} />
<Text style={[styles.errorText, { color: colors.danger }]}>{searchErrorMessage}</Text>
</View>
</View>
)}
{aiResults && (
<View style={{ paddingHorizontal: 20, paddingTop: 12 }}>
<TouchableOpacity
style={[styles.clearAiResultsBtn, { backgroundColor: colors.surface }]}
onPress={() => {
setAiResults(null);
setSearchErrorMessage(null);
}}
>
<Ionicons name="close-circle" size={18} color={colors.textSecondary} />
<Text style={{ color: colors.textSecondary, fontSize: 13, fontWeight: '500' }}>
{(t as any).clearAiResults || 'KI-Ergebnisse löschen'}
</Text>
</TouchableOpacity>
</View>
)}
{showSearchHistory ? (
<View style={styles.historySection}>
<View style={styles.historyHeader}>
<Text style={[styles.historyTitle, { color: colors.textSecondary }]}>{t.searchHistory}</Text>
<TouchableOpacity onPress={handleClearHistory} hitSlop={8}>
<Text style={[styles.clearHistoryText, { color: colors.primaryDark }]}>{t.clearHistory}</Text>
</TouchableOpacity>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.historyContent}
>
{searchHistory.map((item, index) => (
<TouchableOpacity
key={`${item}-${index}`}
style={[styles.historyChip, { backgroundColor: colors.chipBg, borderColor: colors.chipBorder }]}
onPress={() => handleHistorySelect(item)}
activeOpacity={0.8}
>
<Ionicons name="time-outline" size={14} color={colors.textMuted} />
<Text style={[styles.historyChipText, { color: colors.text }]} numberOfLines={1}>
{item}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
) : null}
{/* Grid */}
<FlatList
data={lexiconPlants}
numColumns={3}
keyExtractor={(_, i) => i.toString()}
contentContainerStyle={styles.grid}
columnWrapperStyle={styles.gridRow}
showsVerticalScrollIndicator={false}
initialNumToRender={12}
maxToRenderPerBatch={6}
windowSize={3}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={{ color: colors.textMuted }}>{t.noResults}</Text>
</View>
}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}
activeOpacity={0.8}
onPress={() => setSelectedItem(item as any)}
>
<SafeImage
uri={item.imageUri}
categories={item.categories}
fallbackMode="category"
placeholderLabel={item.name}
style={styles.cardImage}
/>
<View style={styles.cardContent}>
<Text style={[styles.cardName, { color: colors.text }]} numberOfLines={1}>
{item.name}
</Text>
<Text style={[styles.cardBotanical, { color: colors.textMuted }]} numberOfLines={1}>
{item.botanicalName}
</Text>
</View>
</TouchableOpacity>
)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, overflow: 'hidden' },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingBottom: 16,
borderBottomWidth: 1,
gap: 14,
},
backBtn: { marginLeft: -8, padding: 4 },
title: { fontSize: 19, fontWeight: '700', letterSpacing: 0.2 },
searchBar: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 10,
gap: 10,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.14,
shadowRadius: 8,
elevation: 2,
},
searchInput: { flex: 1, fontSize: 15 },
historySection: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 8 },
historyHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
historyTitle: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.8,
textTransform: 'uppercase',
},
clearHistoryText: { fontSize: 12, fontWeight: '700' },
historyContent: { gap: 8, paddingRight: 20 },
historyChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 10,
paddingVertical: 8,
},
historyChipText: { fontSize: 12, fontWeight: '600' },
grid: { padding: 20, paddingBottom: 40 },
gridRow: { gap: 10, marginBottom: 10 },
card: {
flex: 1,
borderRadius: 18,
borderWidth: 1,
overflow: 'hidden',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.14,
shadowRadius: 6,
elevation: 2,
},
cardImage: { width: '100%', aspectRatio: 1, resizeMode: 'cover' },
cardContent: { padding: 8 },
cardName: { fontSize: 12, fontWeight: '700' },
cardBotanical: { fontSize: 9, fontStyle: 'italic' },
empty: { paddingVertical: 40, alignItems: 'center' },
aiSearchBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 14,
borderWidth: 1,
borderStyle: 'dashed',
gap: 10,
},
aiSearchText: {
fontSize: 14,
fontWeight: '600',
},
errorBox: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 12,
gap: 10,
},
errorText: {
fontSize: 14,
fontWeight: '500',
flex: 1,
},
clearAiResultsBtn: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 20,
gap: 6,
},
});