Initial commit for Greenlens

This commit is contained in:
Timo Knuth
2026-03-16 21:31:46 +01:00
parent 307135671f
commit 05d4f6e78b
573 changed files with 54233 additions and 1891 deletions

58
app/(tabs)/_layout.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
export default function TabLayout() {
const { isDarkMode, colorPalette, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textMuted,
tabBarStyle: {
backgroundColor: colors.tabBarBg,
borderTopColor: colors.tabBarBorder,
height: 85,
paddingTop: 8,
paddingBottom: 28,
},
tabBarLabelStyle: {
fontSize: 10,
fontWeight: '600',
},
}}
>
<Tabs.Screen
name="index"
options={{
title: t.tabPlants,
tabBarIcon: ({ color, size }) => (
<Ionicons name="leaf-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="search"
options={{
title: t.tabSearch,
tabBarIcon: ({ color, size }) => (
<Ionicons name="search-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: t.tabProfile,
tabBarIcon: ({ color, size }) => (
<Ionicons name="person-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}

846
app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,846 @@
import React, { useMemo, useState, useRef, useEffect } from 'react';
import {
ActivityIndicator,
Alert,
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
Dimensions,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { SafeImage } from '../../components/SafeImage';
import { Plant } from '../../types';
import { useCoachMarks } from '../../context/CoachMarksContext';
const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window');
type FilterKey = 'all' | 'today' | 'week' | 'healthy' | 'dormant';
const DAY_MS = 24 * 60 * 60 * 1000;
const CONTENT_BOTTOM_PADDING = 12;
const FAB_BOTTOM_OFFSET = 16;
function OnboardingChecklist({ plantsCount, colors, router, t }: { plantsCount: number; colors: any; router: any; t: any }) {
const checklist = [
{ id: 'scan', label: t.stepScan, completed: plantsCount > 0, icon: 'camera-outline', route: '/scanner' },
{ id: 'lexicon', label: t.stepLexicon, completed: false, icon: 'search-outline', route: '/lexicon' },
{ id: 'theme', label: t.stepTheme, completed: false, icon: 'color-palette-outline', route: '/profile/preferences' },
];
return (
<View style={[styles.checklistCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.checklistTitle, { color: colors.text }]}>{t.nextStepsTitle}</Text>
<View style={styles.checklistGrid}>
{checklist.map((item) => (
<TouchableOpacity
key={item.id}
style={styles.checklistItem}
onPress={() => {
if (item.id === 'theme') {
router.push('/profile/preferences');
} else if (item.id === 'scan') {
router.push('/scanner');
} else if (item.id === 'lexicon') {
router.push('/lexicon');
} else {
router.push(item.route);
}
}}
disabled={item.completed}
>
<View style={[styles.checkIcon, { backgroundColor: item.completed ? colors.successSoft : colors.surfaceMuted }]}>
<Ionicons
name={item.completed ? 'checkmark-circle' : item.icon as any}
size={18}
color={item.completed ? colors.success : colors.textMuted}
/>
</View>
<Text
style={[
styles.checklistText,
{
color: item.completed ? colors.textMuted : colors.text,
textDecorationLine: item.completed ? 'line-through' : 'none',
},
]}
numberOfLines={1}
>
{item.label}
</Text>
{!item.completed && <Ionicons name="chevron-forward" size={12} color={colors.textMuted} />}
</TouchableOpacity>
))}
</View>
</View>
);
}
const getDaysUntilWatering = (plant: Plant): number => {
const lastWateredTs = new Date(plant.lastWatered).getTime();
if (Number.isNaN(lastWateredTs)) return 0;
const dueTs = lastWateredTs + (plant.careInfo.waterIntervalDays * DAY_MS);
const remainingMs = dueTs - Date.now();
if (remainingMs <= 0) return 0;
return Math.ceil(remainingMs / DAY_MS);
};
export default function HomeScreen() {
const {
plants,
isLoadingPlants,
profileImageUri,
profileName,
billingSummary,
isLoadingBilling,
language,
t,
isDarkMode,
colorPalette,
} = useApp();
const colors = useColors(isDarkMode, colorPalette);
const router = useRouter();
const insets = useSafeAreaInsets();
const [activeFilter, setActiveFilter] = useState<FilterKey>('all');
const { registerLayout, startTour } = useCoachMarks();
const fabRef = useRef<View>(null);
// Tour nach Registrierung starten
useEffect(() => {
const checkTour = async () => {
const flag = await AsyncStorage.getItem('greenlens_show_tour');
if (flag !== 'true') return;
await AsyncStorage.removeItem('greenlens_show_tour');
// 1 Sekunde warten, dann Tour starten
setTimeout(() => {
// Tab-Positionen approximieren (gleichmäßig verteilt)
const tabBarBottom = SCREEN_H - 85;
const tabW = SCREEN_W / 3;
registerLayout('tab_search', { x: tabW, y: tabBarBottom + 8, width: tabW, height: 52 });
registerLayout('tab_profile', { x: tabW * 2, y: tabBarBottom + 8, width: tabW, height: 52 });
startTour([
{
elementKey: 'fab',
title: t.tourFabTitle,
description: t.tourFabDesc,
tooltipSide: 'above',
},
{
elementKey: 'tab_search',
title: t.tourSearchTitle,
description: t.tourSearchDesc,
tooltipSide: 'above',
},
{
elementKey: 'tab_profile',
title: t.tourProfileTitle,
description: t.tourProfileDesc,
tooltipSide: 'above',
},
]);
}, 1000);
};
checkTour();
}, []);
const copy = t;
const greetingText = useMemo(() => {
const hour = new Date().getHours();
if (hour < 12) return copy.greetingMorning;
if (hour < 18) return copy.greetingAfternoon;
return copy.greetingEvening;
}, [copy.greetingAfternoon, copy.greetingEvening, copy.greetingMorning]);
const creditsText = useMemo(() => {
if (isLoadingBilling && !billingSummary) {
return '...';
}
if (!billingSummary) {
return `-- ${copy.creditsLabel}`;
}
return `${billingSummary.credits.available} ${copy.creditsLabel}`;
}, [billingSummary, copy.creditsLabel, isLoadingBilling]);
const thirstyCount = useMemo(
() => plants.filter(plant => getDaysUntilWatering(plant) === 0).length,
[plants]
);
const dueTodayPlants = useMemo(
() => plants.filter(plant => getDaysUntilWatering(plant) === 0),
[plants]
);
const filteredPlants = useMemo(() => {
return plants.filter((plant) => {
if (activeFilter === 'all') return true;
const daysUntil = getDaysUntilWatering(plant);
if (activeFilter === 'today') return daysUntil === 0;
if (activeFilter === 'week') return daysUntil <= 7;
if (activeFilter === 'healthy') return daysUntil >= 2;
return plant.careInfo.waterIntervalDays >= 14;
});
}, [plants, activeFilter]);
const chips: Array<{ key: FilterKey; label: string }> = [
{ key: 'all', label: copy.all },
{ key: 'today', label: copy.today },
{ key: 'week', label: copy.week },
{ key: 'healthy', label: copy.healthy },
{ key: 'dormant', label: copy.dormant },
];
const handleBellPress = () => {
setActiveFilter('today');
if (dueTodayPlants.length === 0) {
Alert.alert(copy.reminderTitle, copy.reminderNone);
return;
}
const previewNames = dueTodayPlants
.slice(0, 6)
.map((plant) => `- ${plant.name}`)
.join('\n');
const remainingCount = dueTodayPlants.length - 6;
const remainingText = remainingCount > 0
? `\n+ ${remainingCount} ${copy.more}`
: '';
Alert.alert(
copy.reminderTitle,
`${copy.reminderDue.replace('{0}', dueTodayPlants.length.toString())}\n\n${previewNames}${remainingText}`
);
};
if (isLoadingPlants) {
return (
<SafeAreaView style={[styles.loadingContainer, { backgroundColor: colors.background }]} edges={['top', 'left', 'right']}>
<ThemeBackdrop colors={colors} />
<ActivityIndicator size="large" color={colors.primary} />
</SafeAreaView>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={['top', 'left', 'right']}>
<ThemeBackdrop colors={colors} />
<ScrollView
contentContainerStyle={[
styles.content,
{ paddingBottom: Math.max(CONTENT_BOTTOM_PADDING, insets.bottom + CONTENT_BOTTOM_PADDING) },
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<View style={styles.headerLeft}>
<View style={[styles.avatarWrap, { backgroundColor: colors.primaryDark }]}>
{profileImageUri ? (
<Image source={{ uri: profileImageUri }} style={styles.avatarImage} />
) : (
<Ionicons name="leaf" size={20} color={colors.iconOnImage} />
)}
</View>
<View style={styles.headerTextBlock}>
<Text style={[styles.greetingText, { color: colors.textSecondary }]}>{greetingText}</Text>
<View style={styles.nameRow}>
<Text style={[styles.nameText, { color: colors.text }]} numberOfLines={1}>
{profileName || ''}
</Text>
<View
style={[
styles.creditsPill,
{
backgroundColor: colors.cardBg,
borderColor: colors.cardBorder,
},
]}
>
<Text style={[styles.creditsText, { color: colors.textSecondary }]} numberOfLines={1}>
{creditsText}
</Text>
</View>
</View>
</View>
</View>
<TouchableOpacity
style={[
styles.bellBtn,
{
backgroundColor: colors.cardBg,
borderColor: colors.cardBorder,
shadowColor: colors.cardShadow,
},
]}
onPress={handleBellPress}
activeOpacity={0.8}
>
<Ionicons name="notifications-outline" size={20} color={colors.text} />
</TouchableOpacity>
</View>
<View style={[styles.priorityCard, { backgroundColor: colors.primaryDark }]}>
<View style={styles.priorityLabelRow}>
<Ionicons name="water-outline" size={14} color={colors.heroButton} />
<Text style={[styles.priorityLabel, { color: colors.heroButton }]}>{copy.needsWaterToday}</Text>
</View>
<Text style={[styles.priorityTitle, { color: colors.iconOnImage }]}>
{copy.plantsThirsty.replace('{0}', thirstyCount.toString())}
</Text>
<TouchableOpacity
style={[
styles.priorityButton,
{
backgroundColor: colors.heroButton,
borderColor: colors.heroButtonBorder,
},
]}
onPress={handleBellPress}
activeOpacity={0.85}
>
<Text style={[styles.priorityButtonText, { color: colors.text }]}>{copy.viewSchedule}</Text>
<Ionicons name="arrow-forward" size={14} color={colors.text} />
</TouchableOpacity>
<Ionicons
name="water"
size={124}
color={colors.overlay}
style={styles.priorityBgIcon}
/>
</View>
{plants.length === 0 && (
<OnboardingChecklist plantsCount={plants.length} colors={colors} router={router} t={t} />
)}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterRow}
>
{chips.map(chip => (
<TouchableOpacity
key={chip.key}
style={[
activeFilter === chip.key ? styles.filterChipActive : styles.filterChip,
activeFilter === chip.key
? { backgroundColor: colors.primary, shadowColor: colors.fabShadow }
: { backgroundColor: colors.cardBg, borderColor: colors.cardBorder },
]}
onPress={() => setActiveFilter(chip.key)}
activeOpacity={0.85}
>
<Text
style={[
activeFilter === chip.key ? styles.filterTextActive : styles.filterText,
{ color: activeFilter === chip.key ? colors.onPrimary : colors.textSecondary },
]}
>
{chip.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
<View style={styles.collectionHeader}>
<Text style={[styles.collectionTitle, { color: colors.text }]}>{copy.collectionTitle}</Text>
<View style={[styles.collectionCountPill, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
<Text style={[styles.collectionCountText, { color: colors.textSecondary }]}>
{copy.collectionCount.replace('{0}', filteredPlants.length.toString())}
</Text>
</View>
</View>
{filteredPlants.length === 0 ? (
<View style={[styles.emptyState, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
<View style={[styles.emptyIconWrap, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="leaf-outline" size={28} color={colors.textMuted} />
</View>
<Text style={[styles.emptyTitle, { color: colors.text }]}>
{plants.length === 0 ? copy.emptyCollectionTitle : copy.noneInFilter}
</Text>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{plants.length === 0 ? copy.emptyCollectionHint : copy.noneInFilter}
</Text>
{plants.length === 0 && (
<TouchableOpacity
style={[styles.emptyCta, { backgroundColor: colors.primary, shadowColor: colors.fabShadow }]}
onPress={() => router.push('/scanner')}
activeOpacity={0.86}
>
<Ionicons name="scan-outline" size={15} color={colors.onPrimary} />
<Text style={[styles.emptyCtaText, { color: colors.onPrimary }]}>{copy.scanFirstPlant}</Text>
</TouchableOpacity>
)}
</View>
) : (
filteredPlants.map((plant) => {
const daysUntil = getDaysUntilWatering(plant);
const thirsty = daysUntil === 0;
const nextWaterText = thirsty
? copy.today
: t.inXDays.replace('{0}', daysUntil.toString());
return (
<TouchableOpacity
key={plant.id}
style={[
styles.plantCard,
{
backgroundColor: colors.cardBg,
borderColor: colors.cardBorder,
shadowColor: colors.cardShadow,
},
]}
activeOpacity={0.9}
onPress={() => router.push(`/plant/${plant.id}`)}
>
<View style={[styles.plantImageWrap, { borderColor: colors.border }]}>
<SafeImage uri={plant.imageUri} style={[styles.plantImage, { backgroundColor: colors.surfaceStrong }]} />
</View>
<View style={styles.plantBody}>
<View style={styles.plantHeadRow}>
<View style={styles.plantTitleCol}>
<Text style={[styles.plantName, { color: colors.text }]} numberOfLines={1}>
{plant.name}
</Text>
<Text style={[styles.botanicalName, { color: colors.textSecondary }]} numberOfLines={1}>
{plant.botanicalName}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
<View style={styles.metaRow}>
<View
style={[
thirsty ? styles.statusThirsty : styles.statusHealthy,
{ backgroundColor: thirsty ? colors.dangerSoft : colors.successSoft },
]}
>
<Text
style={[
thirsty ? styles.statusThirstyText : styles.statusHealthyText,
{ color: thirsty ? colors.danger : colors.success },
]}
>
{thirsty ? copy.thirsty : copy.healthyStatus}
</Text>
</View>
<View style={[styles.nextWaterPill, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}>
<Ionicons name="water-outline" size={12} color={colors.textMuted} />
<Text style={[styles.waterMeta, { color: colors.textMuted }]}>
{copy.nextWaterLabel}: {nextWaterText}
</Text>
</View>
</View>
</View>
</TouchableOpacity>
);
})
)}
</ScrollView>
<TouchableOpacity
ref={fabRef}
style={[
styles.fab,
{ bottom: Math.max(FAB_BOTTOM_OFFSET, insets.bottom + FAB_BOTTOM_OFFSET) },
{
backgroundColor: colors.fabBg,
borderColor: colors.surface,
shadowColor: colors.fabShadow,
},
]}
activeOpacity={0.8}
onPress={() => router.push('/scanner')}
onLayout={() => {
fabRef.current?.measureInWindow((x, y, width, height) => {
registerLayout('fab', { x, y, width, height });
});
}}
>
<Ionicons name="add" size={30} color={colors.onPrimary} />
</TouchableOpacity>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
content: {
paddingHorizontal: 24,
paddingTop: 14,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 26,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
flex: 1,
},
headerTextBlock: {
flex: 1,
minWidth: 0,
},
avatarWrap: {
width: 48,
height: 48,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
avatarImage: {
width: '100%',
height: '100%',
},
greetingText: {
fontSize: 13,
fontWeight: '600',
},
nameRow: {
marginTop: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
minWidth: 0,
},
nameText: {
fontSize: 21,
fontWeight: '700',
letterSpacing: 0.1,
flexShrink: 1,
},
creditsPill: {
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 8,
paddingVertical: 3,
},
creditsText: {
fontSize: 10,
fontWeight: '700',
letterSpacing: 0.3,
textTransform: 'uppercase',
},
bellBtn: {
width: 46,
height: 46,
borderRadius: 16,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
elevation: 2,
},
priorityCard: {
borderRadius: 32,
padding: 24,
marginBottom: 20,
overflow: 'hidden',
},
priorityLabelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginBottom: 8,
},
priorityLabel: {
fontSize: 10,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.9,
},
priorityTitle: {
fontSize: 29,
fontWeight: '700',
lineHeight: 36,
maxWidth: 260,
marginBottom: 14,
},
priorityButton: {
alignSelf: 'flex-start',
borderRadius: 16,
borderWidth: 1,
paddingHorizontal: 16,
paddingVertical: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
priorityButtonText: {
fontSize: 13,
fontWeight: '700',
},
priorityBgIcon: {
position: 'absolute',
right: -18,
bottom: -22,
},
filterRow: {
gap: 10,
paddingVertical: 2,
paddingRight: 4,
marginBottom: 16,
},
collectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
collectionTitle: {
fontSize: 19,
fontWeight: '700',
letterSpacing: 0.2,
},
collectionCountPill: {
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 10,
paddingVertical: 4,
},
collectionCountText: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
filterChip: {
paddingHorizontal: 22,
paddingVertical: 10,
borderRadius: 16,
borderWidth: 1,
},
filterChipActive: {
paddingHorizontal: 22,
paddingVertical: 10,
borderRadius: 16,
shadowOpacity: 0.24,
shadowRadius: 10,
shadowOffset: { width: 0, height: 3 },
elevation: 3,
},
filterText: {
fontSize: 13,
fontWeight: '700',
},
filterTextActive: {
fontSize: 13,
fontWeight: '700',
},
plantCard: {
borderRadius: 30,
borderWidth: 1,
padding: 15,
flexDirection: 'row',
alignItems: 'flex-start',
gap: 14,
marginBottom: 14,
shadowOpacity: 0.1,
shadowRadius: 12,
shadowOffset: { width: 0, height: 4 },
elevation: 3,
},
plantImageWrap: {
borderWidth: 1,
borderRadius: 20,
overflow: 'hidden',
},
plantImage: {
width: 98,
height: 98,
borderRadius: 20,
},
plantBody: {
flex: 1,
paddingTop: 2,
},
plantHeadRow: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 8,
gap: 8,
},
plantTitleCol: {
flex: 1,
minWidth: 0,
},
plantName: {
fontWeight: '700',
fontSize: 19,
letterSpacing: 0.15,
},
botanicalName: {
fontSize: 12.5,
fontStyle: 'italic',
fontWeight: '500',
marginTop: 2,
opacity: 0.95,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
},
statusThirsty: {
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 4,
},
statusHealthy: {
borderRadius: 20,
paddingHorizontal: 10,
paddingVertical: 4,
},
statusThirstyText: {
fontSize: 10,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
statusHealthyText: {
fontSize: 10,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
waterMeta: {
fontSize: 10,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
nextWaterPill: {
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 9,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
emptyState: {
borderRadius: 24,
borderWidth: 1,
paddingVertical: 32,
paddingHorizontal: 22,
alignItems: 'center',
gap: 10,
},
emptyIconWrap: {
width: 56,
height: 56,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
emptyTitle: {
fontSize: 18,
fontWeight: '700',
textAlign: 'center',
},
emptyText: {
fontSize: 13,
fontWeight: '500',
textAlign: 'center',
lineHeight: 20,
},
emptyCta: {
marginTop: 8,
borderRadius: 999,
paddingHorizontal: 16,
paddingVertical: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
shadowOpacity: 0.3,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
emptyCtaText: {
fontSize: 13,
fontWeight: '700',
},
fab: {
position: 'absolute',
right: 24,
width: 66,
height: 66,
borderRadius: 33,
borderWidth: 4,
alignItems: 'center',
justifyContent: 'center',
shadowOpacity: 0.38,
shadowRadius: 14,
shadowOffset: { width: 0, height: 5 },
elevation: 9,
},
checklistCard: {
borderRadius: 24,
borderWidth: 1,
padding: 20,
marginBottom: 20,
},
checklistTitle: {
fontSize: 16,
fontWeight: '700',
marginBottom: 16,
},
checklistGrid: {
gap: 12,
},
checklistItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
checkIcon: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
checklistText: {
flex: 1,
fontSize: 14,
fontWeight: '500',
},
});

526
app/(tabs)/profile.tsx Normal file
View File

@@ -0,0 +1,526 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
Image,
Alert,
TextInput,
Keyboard,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { useRouter } from 'expo-router';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types';
const DAY_MS = 24 * 60 * 60 * 1000;
const getDaysUntilWatering = (lastWatered: string, intervalDays: number): number => {
const lastWateredTs = new Date(lastWatered).getTime();
if (Number.isNaN(lastWateredTs)) return 0;
const dueTs = lastWateredTs + (intervalDays * DAY_MS);
const remainingMs = dueTs - Date.now();
if (remainingMs <= 0) return 0;
return Math.ceil(remainingMs / DAY_MS);
};
const getProfileCopy = (language: Language) => {
if (language === 'de') {
return {
overviewLabel: 'Überblick',
statPlants: 'Pflanzen',
statDueToday: 'Heute fällig',
statReminders: 'Erinnerungen an',
account: 'Account',
changePhoto: 'Foto ändern',
removePhoto: 'Foto entfernen',
nameLabel: 'Name',
namePlaceholder: 'Dein Name',
saveName: 'Name speichern',
photoErrorTitle: 'Fehler',
photoErrorMessage: 'Profilfoto konnte nicht geladen werden.',
nameErrorTitle: 'Name fehlt',
nameErrorMessage: 'Bitte gib einen Namen ein.',
menuSettings: 'Einstellungen',
menuBilling: 'Abo & Credits',
menuData: 'Daten & Datenschutz',
logout: 'Abmelden',
logoutConfirmTitle: 'Abmelden?',
logoutConfirmMessage: 'Möchtest du dich wirklich abmelden?',
logoutConfirmBtn: 'Abmelden',
};
}
if (language === 'es') {
return {
overviewLabel: 'Resumen',
statPlants: 'Plantas',
statDueToday: 'Vencen hoy',
statReminders: 'Recordatorios',
account: 'Cuenta',
changePhoto: 'Cambiar foto',
removePhoto: 'Eliminar foto',
nameLabel: 'Nombre',
namePlaceholder: 'Tu nombre',
saveName: 'Guardar nombre',
photoErrorTitle: 'Error',
photoErrorMessage: 'No se pudo cargar la foto.',
nameErrorTitle: 'Falta nombre',
nameErrorMessage: 'Por favor ingresa un nombre.',
menuSettings: 'Ajustes',
menuBilling: 'Suscripción y Créditos',
menuData: 'Datos y Privacidad',
logout: 'Cerrar sesión',
logoutConfirmTitle: '¿Cerrar sesión?',
logoutConfirmMessage: '¿Realmente quieres cerrar sesión?',
logoutConfirmBtn: 'Cerrar sesión',
};
}
return {
overviewLabel: 'Overview',
statPlants: 'Plants',
statDueToday: 'Due today',
statReminders: 'Reminders on',
account: 'Account',
changePhoto: 'Change photo',
removePhoto: 'Remove photo',
nameLabel: 'Name',
namePlaceholder: 'Your name',
saveName: 'Save name',
photoErrorTitle: 'Error',
photoErrorMessage: 'Could not load profile photo.',
nameErrorTitle: 'Name missing',
nameErrorMessage: 'Please enter a name.',
menuSettings: 'Preferences',
menuBilling: 'Billing & Credits',
menuData: 'Data & Privacy',
logout: 'Sign Out',
logoutConfirmTitle: 'Sign out?',
logoutConfirmMessage: 'Do you really want to sign out?',
logoutConfirmBtn: 'Sign Out',
};
};
export default function ProfileScreen() {
const {
plants,
language,
t,
isDarkMode,
colorPalette,
profileImageUri,
setProfileImage,
profileName,
setProfileName,
signOut,
} = useApp();
const router = useRouter();
const colors = useColors(isDarkMode, colorPalette);
const [isUpdatingImage, setIsUpdatingImage] = useState(false);
const [isSavingName, setIsSavingName] = useState(false);
const [draftName, setDraftName] = useState(profileName);
const copy = useMemo(() => getProfileCopy(language), [language]);
useEffect(() => {
setDraftName(profileName);
}, [profileName]);
const normalizedDraftName = draftName.trim();
const canSaveName = normalizedDraftName.length > 0 && normalizedDraftName !== profileName;
const dueTodayCount = useMemo(
() => plants.filter(plant => getDaysUntilWatering(plant.lastWatered, plant.careInfo.waterIntervalDays) === 0).length,
[plants]
);
const remindersEnabledCount = useMemo(
() => plants.filter(plant => Boolean(plant.notificationsEnabled)).length,
[plants]
);
const handlePickProfileImage = async () => {
if (isUpdatingImage) return;
setIsUpdatingImage(true);
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.85,
base64: true,
});
if (!result.canceled && result.assets[0]) {
const asset = result.assets[0];
const imageUri = asset.base64
? `data:image/jpeg;base64,${asset.base64}`
: asset.uri;
await setProfileImage(imageUri);
}
} catch (error) {
console.error('Failed to pick profile image', error);
Alert.alert(copy.photoErrorTitle, copy.photoErrorMessage);
} finally {
setIsUpdatingImage(false);
}
};
const handleRemovePhoto = async () => {
await setProfileImage(null);
};
const handleSaveName = async () => {
if (!normalizedDraftName) {
Alert.alert(copy.nameErrorTitle, copy.nameErrorMessage);
return;
}
if (!canSaveName) {
Keyboard.dismiss();
return;
}
setIsSavingName(true);
try {
await setProfileName(normalizedDraftName);
Keyboard.dismiss();
} finally {
setIsSavingName(false);
}
};
const menuItems = [
{ label: copy.menuSettings, icon: 'settings-outline', route: '/profile/preferences' as any },
{ label: copy.menuBilling, icon: 'card-outline', route: '/profile/billing' as any },
{ label: copy.menuData, icon: 'shield-checkmark-outline', route: '/profile/data' as any },
];
return (
<SafeAreaView edges={['top', 'left', 'right']} style={[styles.container, { backgroundColor: colors.background }]}>
<ThemeBackdrop colors={colors} />
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<Text style={[styles.title, { color: colors.text }]}>{t.tabProfile}</Text>
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
<Text style={[styles.cardTitle, { color: colors.text }]}>{copy.overviewLabel}</Text>
<View style={styles.statsRow}>
{[
{ label: copy.statPlants, value: plants.length.toString() },
{ label: copy.statDueToday, value: dueTodayCount.toString() },
{ label: copy.statReminders, value: remindersEnabledCount.toString() },
].map((item) => (
<View
key={item.label}
style={[styles.statCard, { backgroundColor: colors.surfaceStrong, borderColor: colors.borderStrong }]}
>
<Text style={[styles.statValue, { color: colors.text }]}>{item.value}</Text>
<Text style={[styles.statLabel, { color: colors.textSecondary }]}>{item.label}</Text>
</View>
))}
</View>
</View>
<View style={[styles.card, styles.accountCard, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
<Text style={[styles.cardTitle, { color: colors.text }]}>{copy.account}</Text>
<View style={styles.accountRow}>
<TouchableOpacity
style={[styles.avatarFrame, { backgroundColor: colors.primaryDark }]}
onPress={handlePickProfileImage}
activeOpacity={0.9}
>
{profileImageUri ? (
<Image source={{ uri: profileImageUri }} style={styles.avatarImage} />
) : (
<Ionicons name="person" size={34} color={colors.iconOnImage} />
)}
</TouchableOpacity>
<View style={styles.accountMeta}>
<Text style={[styles.currentName, { color: colors.text }]}>{profileName}</Text>
<Text style={[styles.plantsCount, { color: colors.textSecondary }]}>
{plants.length} {copy.statPlants}
</Text>
</View>
</View>
<View style={styles.photoButtons}>
<TouchableOpacity
style={[styles.photoActionBtn, { backgroundColor: colors.primary }]}
onPress={handlePickProfileImage}
activeOpacity={0.85}
disabled={isUpdatingImage}
>
<Text style={[styles.photoActionText, { color: colors.onPrimary }]}>
{isUpdatingImage ? '...' : copy.changePhoto}
</Text>
</TouchableOpacity>
{profileImageUri ? (
<TouchableOpacity
style={[styles.photoActionBtn, styles.photoSecondaryBtn, { borderColor: colors.borderStrong }]}
onPress={handleRemovePhoto}
activeOpacity={0.85}
>
<Text style={[styles.photoSecondaryText, { color: colors.textSecondary }]}>
{copy.removePhoto}
</Text>
</TouchableOpacity>
) : null}
</View>
<Text style={[styles.fieldLabel, { color: colors.textSecondary }]}>{copy.nameLabel}</Text>
<View style={styles.nameRow}>
<TextInput
style={[
styles.nameInput,
{
color: colors.text,
backgroundColor: colors.inputBg,
borderColor: colors.inputBorder,
},
]}
value={draftName}
placeholder={copy.namePlaceholder}
placeholderTextColor={colors.textMuted}
onChangeText={setDraftName}
maxLength={40}
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleSaveName}
/>
<TouchableOpacity
style={[
styles.saveNameBtn,
{
backgroundColor: canSaveName ? colors.primary : colors.surfaceStrong,
borderColor: canSaveName ? colors.primaryDark : colors.borderStrong,
},
]}
onPress={handleSaveName}
disabled={!canSaveName || isSavingName}
activeOpacity={0.85}
>
<Text style={[styles.saveNameText, { color: canSaveName ? colors.onPrimary : colors.textMuted }]}>
{isSavingName ? '...' : copy.saveName}
</Text>
</TouchableOpacity>
</View>
</View>
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder, padding: 0, overflow: 'hidden' }]}>
{menuItems.map((item, idx) => (
<TouchableOpacity
key={item.route}
style={[
styles.menuItem,
idx !== menuItems.length - 1 && { borderBottomWidth: 1, borderBottomColor: colors.borderStrong }
]}
onPress={() => router.push(item.route)}
activeOpacity={0.7}
>
<View style={styles.menuItemLeft}>
<Ionicons name={item.icon as any} size={20} color={colors.text} />
<Text style={[styles.menuItemText, { color: colors.text }]}>{item.label}</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
</TouchableOpacity>
))}
</View>
{/* Logout */}
<TouchableOpacity
style={[styles.logoutBtn, { borderColor: colors.dangerSoft, backgroundColor: colors.dangerSoft }]}
activeOpacity={0.78}
onPress={() => {
Alert.alert(copy.logoutConfirmTitle, copy.logoutConfirmMessage, [
{ text: t.cancel, style: 'cancel' },
{
text: copy.logoutConfirmBtn,
style: 'destructive',
onPress: async () => {
await signOut();
router.replace('/auth/login');
},
},
]);
}}
>
<Ionicons name="log-out-outline" size={18} color={colors.danger} />
<Text style={[styles.logoutText, { color: colors.danger }]}>{copy.logout}</Text>
</TouchableOpacity>
<View style={{ height: 40 }} />
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 20,
},
scrollContent: {
paddingBottom: 20,
},
title: {
marginTop: 14,
marginBottom: 14,
fontSize: 28,
fontWeight: '700',
},
card: {
borderWidth: 1,
borderRadius: 18,
padding: 14,
marginBottom: 12,
gap: 10,
},
accountCard: {
marginBottom: 14,
},
logoutBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
borderRadius: 14,
borderWidth: 1,
paddingVertical: 14,
marginBottom: 12,
},
logoutText: {
fontSize: 15,
fontWeight: '600',
},
cardTitle: {
fontSize: 15,
fontWeight: '700',
},
statsRow: {
flexDirection: 'row',
gap: 8,
},
statCard: {
flex: 1,
borderWidth: 1,
borderRadius: 12,
paddingVertical: 12,
paddingHorizontal: 8,
alignItems: 'center',
justifyContent: 'center',
gap: 2,
},
statValue: {
fontSize: 22,
lineHeight: 24,
fontWeight: '700',
},
statLabel: {
fontSize: 11,
fontWeight: '600',
textAlign: 'center',
},
accountRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
accountMeta: {
flex: 1,
gap: 3,
},
currentName: {
fontSize: 18,
fontWeight: '700',
},
plantsCount: {
fontSize: 13,
fontWeight: '600',
},
avatarFrame: {
width: 74,
height: 74,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
avatarImage: {
width: '100%',
height: '100%',
},
photoButtons: {
flexDirection: 'row',
gap: 8,
},
photoActionBtn: {
borderRadius: 10,
paddingVertical: 10,
paddingHorizontal: 12,
},
photoSecondaryBtn: {
borderWidth: 1,
},
photoActionText: {
fontSize: 12,
fontWeight: '700',
},
photoSecondaryText: {
fontSize: 12,
fontWeight: '700',
},
fieldLabel: {
marginTop: 2,
fontSize: 12,
fontWeight: '600',
},
nameRow: {
flexDirection: 'row',
gap: 8,
alignItems: 'center',
},
nameInput: {
flex: 1,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
fontWeight: '500',
},
saveNameBtn: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
},
saveNameText: {
fontSize: 12,
fontWeight: '700',
},
menuItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 14,
},
menuItemLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
menuItemText: {
fontSize: 16,
fontWeight: '500',
}
});

613
app/(tabs)/search.tsx Normal file
View File

@@ -0,0 +1,613 @@
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' },
});