614 lines
21 KiB
TypeScript
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' },
|
|
});
|