Initial commit for Greenlens
This commit is contained in:
58
app/(tabs)/_layout.tsx
Normal file
58
app/(tabs)/_layout.tsx
Normal 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
846
app/(tabs)/index.tsx
Normal 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
526
app/(tabs)/profile.tsx
Normal 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
613
app/(tabs)/search.tsx
Normal 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' },
|
||||
});
|
||||
Reference in New Issue
Block a user