import React, { useEffect, useMemo, useState } from 'react'; import { Dimensions, FlatList, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useApp } from '../../context/AppContext'; import { useColors } from '../../constants/Colors'; import { Plant } from '../../types'; import { PlantCard } from '../../components/PlantCard'; import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { DatabaseEntry, PlantDatabaseService, SemanticSearchStatus, } from '../../services/plantDatabaseService'; import { normalizeSearchText, rankHybridEntries } from '../../utils/hybridSearch'; const { width } = Dimensions.get('window'); const CARD_GAP = 12; const CARD_WIDTH = (width - 40 - CARD_GAP) / 2; const SEARCH_DEBOUNCE_MS = 250; const SEMANTIC_SEARCH_CREDIT_COST = 2; const getBillingCopy = (language: 'de' | 'en' | 'es') => { if (language === 'de') { return { creditsLabel: 'Credits', deepSearchCost: `Deep Search kostet ${SEMANTIC_SEARCH_CREDIT_COST} Credits`, insufficientCredits: 'Nicht genug Credits fuer AI Deep Search.', managePlan: 'Plan verwalten', }; } if (language === 'es') { return { creditsLabel: 'Creditos', deepSearchCost: `Deep Search cuesta ${SEMANTIC_SEARCH_CREDIT_COST} creditos`, insufficientCredits: 'No tienes creditos suficientes para AI Deep Search.', managePlan: 'Gestionar plan', }; } return { creditsLabel: 'Credits', deepSearchCost: `Deep Search costs ${SEMANTIC_SEARCH_CREDIT_COST} credits`, insufficientCredits: 'Not enough credits for AI Deep Search.', managePlan: 'Manage plan', }; }; const parseColor = (value: string) => { if (value.startsWith('#')) { const cleaned = value.replace('#', ''); const normalized = cleaned.length === 3 ? cleaned.split('').map((c) => `${c}${c}`).join('') : cleaned; const int = Number.parseInt(normalized, 16); return { r: (int >> 16) & 255, g: (int >> 8) & 255, b: int & 255, }; } const match = value.match(/rgba?\(([^)]+)\)/i); if (!match) return { r: 255, g: 255, b: 255 }; const parts = match[1].split(',').map((part) => part.trim()); return { r: Number.parseFloat(parts[0]) || 255, g: Number.parseFloat(parts[1]) || 255, b: Number.parseFloat(parts[2]) || 255, }; }; const blendColors = (baseColor: string, tintColor: string, tintWeight: number) => { const base = parseColor(baseColor); const tint = parseColor(tintColor); const weight = Math.max(0, Math.min(1, tintWeight)); const r = Math.round(base.r + (tint.r - base.r) * weight); const g = Math.round(base.g + (tint.g - base.g) * weight); const b = Math.round(base.b + (tint.b - base.b) * weight); return `rgb(${r}, ${g}, ${b})`; }; const relativeLuminance = (value: string) => { const { r, g, b } = parseColor(value); const [nr, ng, nb] = [r, g, b].map((channel) => { const normalized = channel / 255; return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; }); return 0.2126 * nr + 0.7152 * ng + 0.0722 * nb; }; const contrastRatio = (a: string, b: string) => { const l1 = relativeLuminance(a); const l2 = relativeLuminance(b); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); }; const pickBestTextColor = (bgColor: string, candidates: string[]) => { let best = candidates[0]; let bestRatio = contrastRatio(bgColor, best); for (let i = 1; i < candidates.length; i += 1) { const ratio = contrastRatio(bgColor, candidates[i]); if (ratio > bestRatio) { best = candidates[i]; bestRatio = ratio; } } return best; }; const chunkIntoRows = (items: T[], size = 2): T[][] => { const rows: T[][] = []; for (let i = 0; i < items.length; i += size) { rows.push(items.slice(i, i + size)); } return rows; }; export default function SearchScreen() { const { plants, isDarkMode, colorPalette, t, language, billingSummary, refreshBillingSummary, } = useApp(); const colors = useColors(isDarkMode, colorPalette); const router = useRouter(); const billingCopy = getBillingCopy(language); const availableCredits = billingSummary?.credits.available ?? 0; const [searchQuery, setSearchQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); const [isDeepSearching, setIsDeepSearching] = useState(false); const [aiStatus, setAiStatus] = useState('idle'); const [aiResults, setAiResults] = useState([]); useEffect(() => { const timer = setTimeout(() => { setDebouncedQuery(searchQuery.trim()); }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); }, [searchQuery]); useEffect(() => { setAiStatus('idle'); setAiResults([]); }, [debouncedQuery, language]); useEffect(() => { refreshBillingSummary(); }, [refreshBillingSummary]); const getCategoryBackground = (baseTint: string, accent: string) => { return isDarkMode ? baseTint : blendColors(baseTint, accent, 0.2); }; const getCategoryTextColor = (bgColor: string, accent: string) => { const tintedDark = blendColors(accent, '#000000', 0.58); const tintedLight = blendColors(accent, '#ffffff', 0.64); return pickBestTextColor(bgColor, [ isDarkMode ? tintedLight : tintedDark, colors.text, colors.textOnImage, ]); }; const categories = [ { id: 'easy', name: t.catCareEasy, bg: getCategoryBackground(colors.successTint, colors.success), accent: colors.success }, { id: 'low_light', name: t.catLowLight, bg: getCategoryBackground(colors.infoTint, colors.info), accent: colors.info }, { id: 'bright_light', name: t.catBrightLight, bg: getCategoryBackground(colors.primaryTint, colors.primaryDark), accent: colors.primaryDark }, { id: 'sun', name: t.catSun, bg: getCategoryBackground(colors.warningTint, colors.warning), accent: colors.warning }, { id: 'pet_friendly', name: t.catPetFriendly, bg: getCategoryBackground(colors.dangerTint, colors.danger), accent: colors.danger }, { id: 'air_purifier', name: t.catAirPurifier, bg: getCategoryBackground(colors.primaryTint, colors.primary), accent: colors.primary }, { id: 'high_humidity', name: t.catHighHumidity, bg: getCategoryBackground(colors.infoTint, colors.info), accent: colors.info }, { id: 'hanging', name: t.catHanging, bg: getCategoryBackground(colors.successTint, colors.success), accent: colors.success }, { id: 'patterned', name: t.catPatterned, bg: getCategoryBackground(colors.dangerTint, colors.primaryDark), accent: colors.primaryDark }, { id: 'flowering', name: t.catFlowering, bg: getCategoryBackground(colors.primaryTint, colors.primaryDark), accent: colors.primaryDark }, { id: 'succulent', name: t.catSucculents, bg: getCategoryBackground(colors.warningTint, colors.warning), accent: colors.warning }, { id: 'tree', name: t.catTree, bg: getCategoryBackground(colors.surfaceStrong, colors.textSecondary), accent: colors.textSecondary }, { id: 'large', name: t.catLarge, bg: getCategoryBackground(colors.surface, colors.textMuted), accent: colors.textMuted }, { id: 'medicinal', name: t.catMedicinal, bg: getCategoryBackground(colors.successTint, colors.success), accent: colors.success }, ]; const normalizedQuery = normalizeSearchText(debouncedQuery); const isResultMode = Boolean(normalizedQuery); const localResults = useMemo(() => { if (!normalizedQuery) { return [] as Plant[]; } return rankHybridEntries(plants, normalizedQuery, 30) .map((entry) => entry.entry); }, [plants, normalizedQuery]); const [lexiconResults, setLexiconResults] = useState([]); useEffect(() => { if (!normalizedQuery) { setLexiconResults([]); return; } let isCancelled = false; PlantDatabaseService.searchPlants(normalizedQuery, language, { limit: 30, }).then((results) => { if (!isCancelled) setLexiconResults(results); }).catch(console.error); return () => { isCancelled = true; }; }, [normalizedQuery, language]); const filteredAiResults = aiResults; const showAiSection = aiStatus !== 'idle' || filteredAiResults.length > 0; const canRunDeepSearch = ( searchQuery.trim().length >= 3 && !isDeepSearching && availableCredits >= SEMANTIC_SEARCH_CREDIT_COST ); const handleDeepSearch = async () => { const query = searchQuery.trim(); if (query.length < 3) return; if (availableCredits < SEMANTIC_SEARCH_CREDIT_COST) { setAiStatus('insufficient_credits'); setAiResults([]); return; } setIsDeepSearching(true); setAiStatus('loading'); setAiResults([]); try { const response = await PlantDatabaseService.semanticSearchDetailed(query, language); setAiStatus(response.status); setAiResults(response.results); } catch (error) { console.error('Deep search failed', error); setAiStatus('provider_error'); setAiResults([]); } finally { setIsDeepSearching(false); await refreshBillingSummary(); } }; const openCategoryLexicon = (categoryId: string, categoryName: string) => { router.push({ pathname: '/lexicon', params: { categoryId, categoryLabel: encodeURIComponent(categoryName), }, }); }; const clearAll = () => { setSearchQuery(''); setDebouncedQuery(''); setAiStatus('idle'); setAiResults([]); }; const openLexiconDetail = (entry: DatabaseEntry) => { router.push({ pathname: '/lexicon', params: { detail: encodeURIComponent(JSON.stringify(entry)) }, }); }; const renderGrid = ( items: Array, type: 'local' | 'lexicon' | 'ai', ) => { const rows = chunkIntoRows(items, 2); return ( {rows.map((row, rowIndex) => ( {row.map((item, itemIndex) => ( { if (type === 'local' && 'id' in item) { router.push(`/plant/${item.id}`); return; } openLexiconDetail(item as DatabaseEntry); }} t={t} isDark={isDarkMode} colorPalette={colorPalette} /> ))} {row.length === 1 ? : null} ))} ); }; const aiStatusText = (() => { if (aiStatus === 'loading') return t.searchAiLoading; if (aiStatus === 'timeout') return t.searchAiUnavailable; if (aiStatus === 'provider_error') return t.searchAiUnavailable; if (aiStatus === 'insufficient_credits') return billingCopy.insufficientCredits; if (aiStatus === 'no_results') return t.searchAiNoResults; return null; })(); const SectionTitle = ({ label, count }: { label: string; count: number }) => ( {label} {count} ); return ( {t.searchTitle} {searchQuery ? ( ) : null} item.id} style={styles.chipsList} showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chipsContent} renderItem={({ item }) => { return ( openCategoryLexicon(item.id, item.name)} activeOpacity={0.8} > {item.name} ); }} /> {searchQuery.trim().length >= 3 ? ( {isDeepSearching ? t.searchAiLoading : t.searchDeepAction} {billingCopy.creditsLabel}: {availableCredits} {billingCopy.deepSearchCost} ) : null} {isResultMode ? ( {localResults.length > 0 ? ( renderGrid(localResults, 'local') ) : ( {t.searchNoLocalResults} )} {lexiconResults.length > 0 ? ( renderGrid(lexiconResults, 'lexicon') ) : ( {t.searchNoLexiconResults} )} {showAiSection ? ( {aiStatus === 'loading' ? ( {aiStatusText} ) : filteredAiResults.length > 0 ? ( renderGrid(filteredAiResults, 'ai') ) : aiStatusText ? ( {aiStatusText} {aiStatus === 'insufficient_credits' ? ( router.push('/(tabs)/profile')} activeOpacity={0.85} > {billingCopy.managePlan} ) : null} ) : null} ) : null} ) : ( router.push('/lexicon')} > {t.lexiconTitle} {t.lexiconDesc} {t.browseLexicon} )} ); } const styles = StyleSheet.create({ container: { flex: 1, paddingHorizontal: 20 }, title: { fontSize: 23, fontWeight: '700', letterSpacing: 0.2, marginTop: 12, marginBottom: 16 }, 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 }, chipsList: { marginTop: 10, height: 50, maxHeight: 50 }, chipsContent: { gap: 8, paddingRight: 4, paddingVertical: 1, alignItems: 'center' }, catChip: { height: 40, paddingHorizontal: 14, paddingVertical: 0, borderRadius: 20, justifyContent: 'center', alignItems: 'center', borderWidth: 1, }, catChipText: { fontSize: 12.5, lineHeight: 18, fontWeight: '700', includeFontPadding: false, }, deepSearchBtn: { marginTop: 12, flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', gap: 8, paddingHorizontal: 11, paddingVertical: 6, borderRadius: 10, borderWidth: 1, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.16, shadowRadius: 6, elevation: 2, }, deepSearchWrap: { marginTop: 12, gap: 6, alignSelf: 'flex-start', }, deepSearchText: { fontSize: 12, fontWeight: '700' }, creditMetaRow: { gap: 1, marginLeft: 2, }, creditMetaText: { fontSize: 11, fontWeight: '600', }, results: { marginTop: 14 }, resultsContent: { paddingBottom: 110 }, sectionHeader: { marginBottom: 10, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, sectionTitle: { fontSize: 15, fontWeight: '600' }, sectionCount: { fontSize: 13, fontWeight: '500' }, grid: { marginBottom: 18 }, gridRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: CARD_GAP, }, cardWrapper: { width: CARD_WIDTH }, cardSpacer: { width: CARD_WIDTH }, emptyText: { marginBottom: 18, fontSize: 14, lineHeight: 20 }, aiSection: { marginTop: 2 }, aiStatusBlock: { marginBottom: 18 }, aiLoadingRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 18 }, aiStatusText: { fontSize: 13, fontWeight: '500' }, managePlanBtn: { alignSelf: 'flex-start', borderWidth: 1, borderRadius: 10, paddingHorizontal: 10, paddingVertical: 6, marginTop: -8, }, managePlanText: { fontSize: 12, fontWeight: '700' }, discoveryContent: { paddingTop: 16, paddingBottom: 120 }, lexiconBanner: { marginTop: 8, padding: 18, borderRadius: 18, gap: 4, }, lexiconTitle: { fontSize: 18, fontWeight: '700' }, lexiconDesc: { fontSize: 12 }, lexiconBadge: { marginTop: 8, paddingHorizontal: 12, paddingVertical: 5, borderRadius: 20, alignSelf: 'flex-start', }, lexiconBadgeText: { fontSize: 11, fontWeight: '700' }, });