452 lines
15 KiB
TypeScript
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,
|
|
},
|
|
});
|