Greenlens/app/(tabs)/search.tsx

614 lines
21 KiB
TypeScript

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 = <T,>(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<SemanticSearchStatus | 'idle' | 'loading'>('idle');
const [aiResults, setAiResults] = useState<DatabaseEntry[]>([]);
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<DatabaseEntry[]>([]);
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<Plant | DatabaseEntry>,
type: 'local' | 'lexicon' | 'ai',
) => {
const rows = chunkIntoRows(items, 2);
return (
<View style={styles.grid}>
{rows.map((row, rowIndex) => (
<View key={`${type}-row-${rowIndex}`} style={styles.gridRow}>
{row.map((item, itemIndex) => (
<View key={`${type}-${item.name}-${itemIndex}`} style={styles.cardWrapper}>
<PlantCard
plant={item}
width={CARD_WIDTH}
onPress={() => {
if (type === 'local' && 'id' in item) {
router.push(`/plant/${item.id}`);
return;
}
openLexiconDetail(item as DatabaseEntry);
}}
t={t}
isDark={isDarkMode}
colorPalette={colorPalette}
/>
</View>
))}
{row.length === 1 ? <View style={styles.cardSpacer} /> : null}
</View>
))}
</View>
);
};
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 }) => (
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{label}</Text>
<Text style={[styles.sectionCount, { color: colors.textSecondary }]}>{count}</Text>
</View>
);
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<ThemeBackdrop colors={colors} />
<Text style={[styles.title, { color: colors.text }]}>{t.searchTitle}</Text>
<View style={[styles.searchBar, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
<Ionicons name="search" size={20} color={colors.textMuted} />
<TextInput
style={[styles.searchInput, { color: colors.text }]}
placeholder={t.searchPlaceholder}
placeholderTextColor={colors.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoCorrect={false}
autoCapitalize="none"
returnKeyType="search"
/>
{searchQuery ? (
<TouchableOpacity onPress={clearAll} hitSlop={8}>
<Ionicons name="close" size={20} color={colors.textMuted} />
</TouchableOpacity>
) : null}
</View>
<FlatList
horizontal
data={categories}
keyExtractor={item => item.id}
style={styles.chipsList}
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.chipsContent}
renderItem={({ item }) => {
return (
<TouchableOpacity
style={[
styles.catChip,
{
backgroundColor: item.bg,
borderColor: colors.chipBorder,
},
]}
onPress={() => openCategoryLexicon(item.id, item.name)}
activeOpacity={0.8}
>
<Text style={[styles.catChipText, { color: getCategoryTextColor(item.bg, item.accent) }]}>
{item.name}
</Text>
</TouchableOpacity>
);
}}
/>
{searchQuery.trim().length >= 3 ? (
<View style={styles.deepSearchWrap}>
<TouchableOpacity
style={[
styles.deepSearchBtn,
{
backgroundColor: canRunDeepSearch ? colors.primary : colors.surfaceStrong,
borderColor: canRunDeepSearch ? colors.primaryDark : colors.borderStrong,
shadowColor: canRunDeepSearch ? colors.fabShadow : colors.cardShadow,
},
]}
onPress={handleDeepSearch}
disabled={!canRunDeepSearch}
activeOpacity={0.85}
>
<Ionicons
name="sparkles"
size={16}
color={canRunDeepSearch ? colors.onPrimary : colors.textMuted}
/>
<Text style={[styles.deepSearchText, { color: canRunDeepSearch ? colors.onPrimary : colors.textMuted }]}>
{isDeepSearching ? t.searchAiLoading : t.searchDeepAction}
</Text>
</TouchableOpacity>
<View style={styles.creditMetaRow}>
<Text style={[styles.creditMetaText, { color: colors.textSecondary }]}>
{billingCopy.creditsLabel}: {availableCredits}
</Text>
<Text style={[styles.creditMetaText, { color: colors.textMuted }]}>
{billingCopy.deepSearchCost}
</Text>
</View>
</View>
) : null}
{isResultMode ? (
<ScrollView style={styles.results} contentContainerStyle={styles.resultsContent} showsVerticalScrollIndicator={false}>
<SectionTitle label={t.searchMyPlants} count={localResults.length} />
{localResults.length > 0 ? (
renderGrid(localResults, 'local')
) : (
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>{t.searchNoLocalResults}</Text>
)}
<SectionTitle label={t.searchLexicon} count={lexiconResults.length} />
{lexiconResults.length > 0 ? (
renderGrid(lexiconResults, 'lexicon')
) : (
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>{t.searchNoLexiconResults}</Text>
)}
{showAiSection ? (
<View style={styles.aiSection}>
<SectionTitle label={t.searchAiSection} count={filteredAiResults.length} />
{aiStatus === 'loading' ? (
<View style={styles.aiLoadingRow}>
<Ionicons name="sparkles" size={14} color={colors.primary} />
<Text style={[styles.aiStatusText, { color: colors.textSecondary }]}>{aiStatusText}</Text>
</View>
) : filteredAiResults.length > 0 ? (
renderGrid(filteredAiResults, 'ai')
) : aiStatusText ? (
<View style={styles.aiStatusBlock}>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>{aiStatusText}</Text>
{aiStatus === 'insufficient_credits' ? (
<TouchableOpacity
style={[styles.managePlanBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surfaceStrong }]}
onPress={() => router.push('/(tabs)/profile')}
activeOpacity={0.85}
>
<Text style={[styles.managePlanText, { color: colors.text }]}>{billingCopy.managePlan}</Text>
</TouchableOpacity>
) : null}
</View>
) : null}
</View>
) : null}
</ScrollView>
) : (
<ScrollView
style={styles.results}
contentContainerStyle={styles.discoveryContent}
showsVerticalScrollIndicator={false}
>
<TouchableOpacity
style={[styles.lexiconBanner, { backgroundColor: colors.primaryDark }]}
activeOpacity={0.85}
onPress={() => router.push('/lexicon')}
>
<Ionicons name="book-outline" size={20} color={colors.iconOnImage} />
<Text style={[styles.lexiconTitle, { color: colors.iconOnImage }]}>{t.lexiconTitle}</Text>
<Text style={[styles.lexiconDesc, { color: colors.heroButton }]}>{t.lexiconDesc}</Text>
<View style={[styles.lexiconBadge, { backgroundColor: colors.heroButtonBorder }]}>
<Text style={[styles.lexiconBadgeText, { color: colors.iconOnImage }]}>{t.browseLexicon}</Text>
</View>
</TouchableOpacity>
</ScrollView>
)}
</SafeAreaView>
);
}
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' },
});