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' },
});

View File

@@ -1,28 +1,42 @@
import { useEffect, useState } from 'react';
import { Stack } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import { Animated, Easing, Image, StyleSheet, Text, View } from 'react-native';
import { Redirect, Stack, usePathname } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { StripeProvider } from '@stripe/stripe-react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
import { Platform } from 'react-native';
import Constants from 'expo-constants';
import { AppProvider, useApp } from '../context/AppContext';
import { CoachMarksProvider } from '../context/CoachMarksContext';
import { CoachMarksOverlay } from '../components/CoachMarksOverlay';
import { useColors } from '../constants/Colors';
import { AuthService } from '../services/authService';
import { initDatabase, AppMetaDb } from '../services/database';
import * as SecureStore from 'expo-secure-store';
import * as SplashScreen from 'expo-splash-screen';
import { AuthService } from '../services/authService';
import { PostHogProvider, usePostHog } from 'posthog-react-native';
type InitialRoute = 'onboarding' | 'auth/login' | '(tabs)';
const STRIPE_PUBLISHABLE_KEY = (process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_mock_key').trim();
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync().catch(() => { });
const POSTHOG_API_KEY = process.env.EXPO_PUBLIC_POSTHOG_API_KEY || 'phc_FX6HRgx9NSpS5moxjMF6xyc37yMwjoeu6TbWUqNNKlk';
const SECURE_INSTALL_MARKER = 'greenlens_install_v1';
const ensureInstallConsistency = async (): Promise<boolean> => {
try {
const [sqliteMarker, secureMarker] = await Promise.all([
Promise.resolve(AppMetaDb.get('install_marker_v2')),
SecureStore.getItemAsync(SECURE_INSTALL_MARKER).catch(() => null),
]);
const sqliteMarker = AppMetaDb.get('install_marker_v2');
const secureMarker = await SecureStore.getItemAsync(SECURE_INSTALL_MARKER).catch(() => null);
if (sqliteMarker && secureMarker) return false; // Kein Fresh Install
if (sqliteMarker === '1' && secureMarker === '1') {
return false; // Alles gut, keine Neuinstallation
}
if (sqliteMarker === '1' || secureMarker === '1') {
// Teilweise vorhanden -> heilen, nicht löschen
AppMetaDb.set('install_marker_v2', '1');
await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1');
return false;
}
// Fresh Install: Alles zurücksetzen
await AuthService.logout();
@@ -36,10 +50,45 @@ const ensureInstallConsistency = async (): Promise<boolean> => {
}
};
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
function RootLayoutInner() {
const { isDarkMode, colorPalette, signOut } = useApp();
const { isDarkMode, colorPalette, signOut, session, isInitializing, isLoadingPlants } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const [initialRoute, setInitialRoute] = useState<InitialRoute | null>(null);
const pathname = usePathname();
const [installCheckDone, setInstallCheckDone] = useState(false);
const [splashAnimationComplete, setSplashAnimationComplete] = useState(false);
const posthog = usePostHog();
useEffect(() => {
// RevenueCat requires native store access — not available in Expo Go
const isExpoGo = Constants.appOwnership === 'expo';
if (isExpoGo) {
console.log('[RevenueCat] Skipping configure: running in Expo Go');
return;
}
Purchases.setLogLevel(LOG_LEVEL.VERBOSE);
const iosApiKey = process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || 'appl_hrSpsuUuVstbHhYIDnOqYxPOnmR';
const androidApiKey = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || 'goog_placeholder';
if (Platform.OS === 'ios') {
Purchases.configure({ apiKey: iosApiKey });
} else if (Platform.OS === 'android') {
Purchases.configure({ apiKey: androidApiKey });
}
}, []);
useEffect(() => {
if (session?.serverUserId) {
posthog.identify(session.serverUserId, {
email: session.email,
name: session.name,
});
} else if (session === null) {
posthog.reset();
}
}, [session, posthog]);
useEffect(() => {
(async () => {
@@ -47,55 +96,92 @@ function RootLayoutInner() {
if (didResetSessionForFreshInstall) {
await signOut();
}
const session = await AuthService.getSession();
if (!session) {
// Kein Benutzer → immer zum Onboarding (Landing + Register/Login)
setInitialRoute('auth/login');
return;
}
const validity = await AuthService.validateWithServer();
if (validity === 'invalid') {
await AuthService.logout();
await signOut();
setInitialRoute('auth/login');
return;
}
// 'valid' or 'unreachable' (offline) → allow tab navigation
setInitialRoute('(tabs)');
setInstallCheckDone(true);
})();
}, [signOut]);
if (initialRoute === null) return null;
const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
let content = null;
if (isAppReady) {
if (!session) {
// Only redirect if we are not already on an auth-related page or the scanner
const isAuthPage = pathname.includes('onboarding') || pathname.includes('auth/') || pathname.includes('scanner');
if (!isAuthPage) {
content = <Redirect href="/onboarding" />;
} else {
content = (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
<Stack.Screen name="auth/login" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/signup" options={{ animation: 'slide_from_right' }} />
<Stack.Screen
name="scanner"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
/>
</Stack>
);
}
} else {
content = (
<>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
<Stack.Screen name="auth/login" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/signup" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="(tabs)" options={{ animation: 'none' }} />
<Stack.Screen
name="scanner"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen
name="plant/[id]"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
<Stack.Screen
name="lexicon"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen
name="profile/preferences"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
<Stack.Screen
name="profile/data"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
<Stack.Screen
name="profile/billing"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
</Stack>
<CoachMarksOverlay />
</>
);
}
}
return (
<>
<StatusBar style={isDarkMode ? 'light' : 'dark'} />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
}}
initialRouteName={initialRoute}
>
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
<Stack.Screen name="auth/login" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/signup" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="(tabs)" options={{ animation: 'none' }} />
<Stack.Screen
name="scanner"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
{content}
{!splashAnimationComplete && (
<AnimatedSplashScreen
isAppReady={isAppReady}
onAnimationComplete={() => setSplashAnimationComplete(true)}
/>
<Stack.Screen
name="plant/[id]"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
<Stack.Screen
name="lexicon"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
/>
</Stack>
{/* Coach Marks rendern über allem */}
<CoachMarksOverlay />
)}
</>
);
}
@@ -104,15 +190,15 @@ export default function RootLayout() {
initDatabase();
return (
<StripeProvider
publishableKey={STRIPE_PUBLISHABLE_KEY}
merchantIdentifier="merchant.com.greenlens"
>
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{
host: 'https://us.i.posthog.com',
enableSessionReplay: true,
}}>
<AppProvider>
<CoachMarksProvider>
<RootLayoutInner />
</CoachMarksProvider>
</AppProvider>
</StripeProvider>
</PostHogProvider>
);
}

View File

@@ -160,7 +160,7 @@ export default function LoginScreen() {
{/* Sign Up Link */}
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => router.push('/auth/signup')}
onPress={() => router.replace('/auth/signup')}
activeOpacity={0.82}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>
@@ -186,9 +186,9 @@ const styles = StyleSheet.create({
marginBottom: 32,
},
logoIcon: {
width: 88,
height: 88,
borderRadius: 20,
width: 56,
height: 56,
borderRadius: 14,
marginBottom: 16,
},
appName: {

View File

@@ -20,8 +20,9 @@ import { AuthService } from '../../services/authService';
import AsyncStorage from '@react-native-async-storage/async-storage';
export default function SignupScreen() {
const { isDarkMode, colorPalette, hydrateSession } = useApp();
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const pendingPlant = getPendingPlant();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
@@ -103,6 +104,16 @@ export default function SignupScreen() {
</Text>
</View>
{/* Pending Plant Hint */}
{pendingPlant && (
<View style={[styles.pendingHint, { backgroundColor: `${colors.primarySoft}40`, borderColor: `${colors.primaryDark}40` }]}>
<Ionicons name="sparkles" size={18} color={colors.primaryDark} />
<Text style={[styles.pendingHintText, { color: colors.primaryDark }]}>
Deine gescannte Pflanze ({pendingPlant.result.name}) wird nach der Registrierung automatisch in deinem Profil gespeichert.
</Text>
</View>
)}
{/* Card */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
{/* Name */}
@@ -256,7 +267,7 @@ export default function SignupScreen() {
</View>
{/* Login link */}
<TouchableOpacity style={styles.loginLink} onPress={() => router.back()}>
<TouchableOpacity style={styles.loginLink} onPress={() => router.replace('/auth/login')}>
<Text style={[styles.loginLinkText, { color: colors.textSecondary }]}>
Bereits ein Konto?{' '}
<Text style={{ color: colors.primary, fontWeight: '600' }}>Anmelden</Text>
@@ -291,9 +302,9 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
logoIcon: {
width: 88,
height: 88,
borderRadius: 20,
width: 56,
height: 56,
borderRadius: 14,
marginBottom: 16,
},
appName: {
@@ -391,4 +402,19 @@ const styles = StyleSheet.create({
loginLinkText: {
fontSize: 15,
},
pendingHint: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 16,
borderWidth: 1,
marginBottom: 20,
gap: 12,
},
pendingHintText: {
flex: 1,
fontSize: 13,
fontWeight: '600',
lineHeight: 18,
},
});

451
app/lexicon.tsx Normal file
View File

@@ -0,0 +1,451 @@
import React, { useState } from 'react';
import {
View, Text, StyleSheet, TextInput, FlatList, TouchableOpacity, Platform, StatusBar, ScrollView, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useApp } from '../context/AppContext';
import { useColors } from '../constants/Colors';
import { PlantDatabaseService } from '../services/plantDatabaseService';
import { IdentificationResult } from '../types';
import { DatabaseEntry } from '../services/plantDatabaseService';
import { ResultCard } from '../components/ResultCard';
import { ThemeBackdrop } from '../components/ThemeBackdrop';
import { SafeImage } from '../components/SafeImage';
import { resolveImageUri } from '../utils/imageUri';
export default function LexiconScreen() {
const { isDarkMode, colorPalette, language, t, savePlant, getLexiconSearchHistory, saveLexiconSearchQuery, clearLexiconSearchHistory } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const insets = useSafeAreaInsets();
const router = useRouter();
const params = useLocalSearchParams();
const categoryIdParam = Array.isArray(params.categoryId) ? params.categoryId[0] : params.categoryId;
const categoryLabelParam = Array.isArray(params.categoryLabel) ? params.categoryLabel[0] : params.categoryLabel;
const decodeParam = (value?: string | string[]) => {
if (!value || typeof value !== 'string') return '';
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const initialCategoryId = typeof categoryIdParam === 'string' ? categoryIdParam : null;
const initialCategoryLabel = decodeParam(categoryLabelParam);
const topInsetFallback = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : 20;
const topInset = insets.top > 0 ? insets.top : topInsetFallback;
const [searchQuery, setSearchQuery] = useState(initialCategoryLabel);
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(initialCategoryId);
const [selectedItem, setSelectedItem] = useState<(IdentificationResult & { imageUri: string }) | null>(null);
const [isAiSearching, setIsAiSearching] = useState(false);
const [aiResults, setAiResults] = useState<DatabaseEntry[] | null>(null);
const [searchErrorMessage, setSearchErrorMessage] = useState<string | null>(null);
const [searchHistory, setSearchHistory] = useState<string[]>([]);
const detailParam = Array.isArray(params.detail) ? params.detail[0] : params.detail;
const openedWithDetail = Boolean(detailParam);
React.useEffect(() => {
if (detailParam) {
try {
const rawParam = detailParam;
const decoded = decodeURIComponent(rawParam as string);
const detail = JSON.parse(decoded);
setSelectedItem(detail);
} catch (e) {
try {
const fallbackRaw = detailParam;
const detail = JSON.parse(fallbackRaw as string);
setSelectedItem(detail);
} catch (fallbackError) {
console.error('Failed to parse plant detail', fallbackError);
}
}
}
}, [detailParam]);
React.useEffect(() => {
setActiveCategoryId(initialCategoryId);
setSearchQuery(initialCategoryLabel);
}, [initialCategoryId, initialCategoryLabel]);
React.useEffect(() => {
const loadHistory = async () => {
const history = getLexiconSearchHistory();
setSearchHistory(history);
};
loadHistory();
}, []);
const handleResultClose = () => {
if (openedWithDetail) {
router.back();
return;
}
setSelectedItem(null);
};
const normalizeText = (value: string): string => (
value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.replace(/\s+/g, ' ')
);
const effectiveSearchQuery = searchQuery;
const [lexiconPlants, setLexiconPlants] = useState<DatabaseEntry[]>([]);
React.useEffect(() => {
if (aiResults) {
setLexiconPlants(aiResults);
return;
}
let isCancelled = false;
PlantDatabaseService.searchPlants(effectiveSearchQuery, language, {
category: activeCategoryId,
limit: 500,
}).then(results => {
if (!isCancelled) setLexiconPlants(results);
}).catch(console.error);
return () => {
isCancelled = true;
};
}, [aiResults, effectiveSearchQuery, language, activeCategoryId]);
const handleAiSearch = async () => {
const query = searchQuery.trim();
if (!query) return;
setIsAiSearching(true);
setAiResults(null);
setSearchErrorMessage(null);
try {
const response = await PlantDatabaseService.semanticSearchDetailed(query, language);
if (response.status === 'success') {
setAiResults(response.results);
saveLexiconSearchQuery(query);
setSearchHistory(getLexiconSearchHistory());
} else if (response.status === 'insufficient_credits') {
setSearchErrorMessage((t as any).errorNoCredits || 'Nicht genügend Guthaben für KI-Suche.');
} else if (response.status === 'no_results') {
setSearchErrorMessage((t as any).noResultsFound || 'Keine Ergebnisse gefunden.');
setAiResults([]);
} else {
setSearchErrorMessage((t as any).errorTryAgain || 'Fehler bei der Suche. Bitte später erneut versuchen.');
}
} catch (error) {
console.error('AI Search failed', error);
setSearchErrorMessage((t as any).errorGeneral || 'Etwas ist schiefgelaufen.');
} finally {
setIsAiSearching(false);
}
};
const handleSearchSubmit = async () => {
const query = searchQuery.trim();
if (!query) return;
saveLexiconSearchQuery(query);
setSearchHistory(getLexiconSearchHistory());
};
const handleHistorySelect = (query: string) => {
setActiveCategoryId(null);
setSearchQuery(query);
};
const handleClearHistory = () => {
clearLexiconSearchHistory();
setSearchHistory([]);
};
const showSearchHistory = searchQuery.trim().length === 0 && !activeCategoryId && searchHistory.length > 0;
if (selectedItem) {
return (
<ResultCard
result={selectedItem}
imageUri={selectedItem.imageUri}
onSave={() => {
savePlant(selectedItem, resolveImageUri(selectedItem.imageUri));
router.back();
}}
onClose={handleResultClose}
t={t}
isDark={isDarkMode}
colorPalette={colorPalette}
/>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={['left', 'right', 'bottom']}>
<ThemeBackdrop colors={colors} />
{/* Header */}
<View
style={[
styles.header,
{
backgroundColor: colors.cardBg,
borderBottomColor: colors.cardBorder,
paddingTop: topInset + 8,
},
]}
>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="arrow-back" size={24} color={colors.textSecondary} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>{t.lexiconTitle}</Text>
</View>
{/* Search */}
<View style={{ paddingHorizontal: 20, paddingTop: 16 }}>
<View
style={[
styles.searchBar,
{ backgroundColor: colors.cardBg, borderColor: colors.inputBorder, shadowColor: colors.cardShadow },
]}
>
<Ionicons name="search" size={20} color={colors.textMuted} />
<TextInput
style={[styles.searchInput, { color: colors.text }]}
placeholder={t.lexiconSearchPlaceholder}
placeholderTextColor={colors.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
onSubmitEditing={handleSearchSubmit}
returnKeyType="search"
/>
{(searchQuery || activeCategoryId) ? (
<TouchableOpacity
onPress={() => {
setSearchQuery('');
setActiveCategoryId(null);
setAiResults(null);
}}
hitSlop={8}
>
<Ionicons name="close" size={18} color={colors.textMuted} />
</TouchableOpacity>
) : null}
</View>
</View>
{/* AI Search Trigger block removed */}
{searchErrorMessage && (
<View style={{ paddingHorizontal: 20, paddingTop: 12 }}>
<View style={[styles.errorBox, { backgroundColor: colors.danger + '20' }]}>
<Ionicons name="alert-circle" size={18} color={colors.danger} />
<Text style={[styles.errorText, { color: colors.danger }]}>{searchErrorMessage}</Text>
</View>
</View>
)}
{aiResults && (
<View style={{ paddingHorizontal: 20, paddingTop: 12 }}>
<TouchableOpacity
style={[styles.clearAiResultsBtn, { backgroundColor: colors.surface }]}
onPress={() => {
setAiResults(null);
setSearchErrorMessage(null);
}}
>
<Ionicons name="close-circle" size={18} color={colors.textSecondary} />
<Text style={{ color: colors.textSecondary, fontSize: 13, fontWeight: '500' }}>
{(t as any).clearAiResults || 'KI-Ergebnisse löschen'}
</Text>
</TouchableOpacity>
</View>
)}
{showSearchHistory ? (
<View style={styles.historySection}>
<View style={styles.historyHeader}>
<Text style={[styles.historyTitle, { color: colors.textSecondary }]}>{t.searchHistory}</Text>
<TouchableOpacity onPress={handleClearHistory} hitSlop={8}>
<Text style={[styles.clearHistoryText, { color: colors.primaryDark }]}>{t.clearHistory}</Text>
</TouchableOpacity>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.historyContent}
>
{searchHistory.map((item, index) => (
<TouchableOpacity
key={`${item}-${index}`}
style={[styles.historyChip, { backgroundColor: colors.chipBg, borderColor: colors.chipBorder }]}
onPress={() => handleHistorySelect(item)}
activeOpacity={0.8}
>
<Ionicons name="time-outline" size={14} color={colors.textMuted} />
<Text style={[styles.historyChipText, { color: colors.text }]} numberOfLines={1}>
{item}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
) : null}
{/* Grid */}
<FlatList
data={lexiconPlants}
numColumns={3}
keyExtractor={(_, i) => i.toString()}
contentContainerStyle={styles.grid}
columnWrapperStyle={styles.gridRow}
showsVerticalScrollIndicator={false}
initialNumToRender={12}
maxToRenderPerBatch={6}
windowSize={3}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={{ color: colors.textMuted }}>{t.noResults}</Text>
</View>
}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}
activeOpacity={0.8}
onPress={() => setSelectedItem(item as any)}
>
<SafeImage
uri={item.imageUri}
categories={item.categories}
fallbackMode="category"
placeholderLabel={item.name}
style={styles.cardImage}
/>
<View style={styles.cardContent}>
<Text style={[styles.cardName, { color: colors.text }]} numberOfLines={1}>
{item.name}
</Text>
<Text style={[styles.cardBotanical, { color: colors.textMuted }]} numberOfLines={1}>
{item.botanicalName}
</Text>
</View>
</TouchableOpacity>
)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, overflow: 'hidden' },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingBottom: 16,
borderBottomWidth: 1,
gap: 14,
},
backBtn: { marginLeft: -8, padding: 4 },
title: { fontSize: 19, fontWeight: '700', letterSpacing: 0.2 },
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 },
historySection: { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 8 },
historyHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
historyTitle: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.8,
textTransform: 'uppercase',
},
clearHistoryText: { fontSize: 12, fontWeight: '700' },
historyContent: { gap: 8, paddingRight: 20 },
historyChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 10,
paddingVertical: 8,
},
historyChipText: { fontSize: 12, fontWeight: '600' },
grid: { padding: 20, paddingBottom: 40 },
gridRow: { gap: 10, marginBottom: 10 },
card: {
flex: 1,
borderRadius: 18,
borderWidth: 1,
overflow: 'hidden',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.14,
shadowRadius: 6,
elevation: 2,
},
cardImage: { width: '100%', aspectRatio: 1, resizeMode: 'cover' },
cardContent: { padding: 8 },
cardName: { fontSize: 12, fontWeight: '700' },
cardBotanical: { fontSize: 9, fontStyle: 'italic' },
empty: { paddingVertical: 40, alignItems: 'center' },
aiSearchBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 14,
borderWidth: 1,
borderStyle: 'dashed',
gap: 10,
},
aiSearchText: {
fontSize: 14,
fontWeight: '600',
},
errorBox: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 12,
gap: 10,
},
errorText: {
fontSize: 14,
fontWeight: '500',
flex: 1,
},
clearAiResultsBtn: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 20,
gap: 6,
},
});

254
app/onboarding.tsx Normal file
View File

@@ -0,0 +1,254 @@
import React, { useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Animated,
Dimensions,
Image,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useApp } from '../context/AppContext';
import { useColors } from '../constants/Colors';
import { ThemeBackdrop } from '../components/ThemeBackdrop';
const { height: SCREEN_H, width: SCREEN_W } = Dimensions.get('window');
export default function OnboardingScreen() {
const { isDarkMode, colorPalette, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const FEATURES = [
{ icon: 'camera-outline' as const, label: t.onboardingFeatureScan },
{ icon: 'notifications-outline' as const, label: t.onboardingFeatureReminder },
{ icon: 'book-outline' as const, label: t.onboardingFeatureLexicon },
];
// Entrance animations
const logoAnim = useRef(new Animated.Value(0)).current;
const logoScale = useRef(new Animated.Value(0.85)).current;
const featuresAnim = useRef(new Animated.Value(0)).current;
const buttonsAnim = useRef(new Animated.Value(0)).current;
const featureAnims = useRef(FEATURES.map(() => new Animated.Value(0))).current;
useEffect(() => {
Animated.sequence([
Animated.parallel([
Animated.timing(logoAnim, { toValue: 1, duration: 700, useNativeDriver: true }),
Animated.spring(logoScale, { toValue: 1, tension: 50, friction: 8, useNativeDriver: true }),
]),
Animated.stagger(100, featureAnims.map(anim =>
Animated.timing(anim, { toValue: 1, duration: 400, useNativeDriver: true })
)),
Animated.timing(buttonsAnim, { toValue: 1, duration: 400, useNativeDriver: true }),
]).start();
}, []);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ThemeBackdrop colors={colors} />
{/* Logo-Bereich */}
<Animated.View
style={[
styles.heroSection,
{ opacity: logoAnim, transform: [{ scale: logoScale }] },
]}
>
<View style={[styles.iconContainer, { shadowColor: colors.primary }]}>
<Image
source={require('../assets/icon.png')}
style={styles.appIcon}
resizeMode="cover"
/>
</View>
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
<Text style={[styles.tagline, { color: colors.textSecondary }]}>
{t.onboardingTagline}
</Text>
</Animated.View>
{/* Feature-Liste */}
<View style={styles.featuresSection}>
{FEATURES.map((feat, i) => (
<Animated.View
key={feat.label}
style={[
styles.featureRow,
{
backgroundColor: colors.surface + '88', // Semi-transparent for backdrop effect
borderColor: colors.border,
opacity: featureAnims[i],
transform: [{
translateY: featureAnims[i].interpolate({
inputRange: [0, 1],
outputRange: [20, 0],
}),
}],
},
]}
>
<View style={[styles.featureIcon, { backgroundColor: colors.primary + '15' }]}>
<Ionicons name={feat.icon} size={22} color={colors.primary} />
</View>
<Text style={[styles.featureText, { color: colors.text }]}>{feat.label}</Text>
</Animated.View>
))}
</View>
{/* Buttons */}
<Animated.View style={[styles.buttonsSection, { opacity: buttonsAnim }]}>
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary }]}
onPress={() => router.push('/scanner')}
activeOpacity={0.85}
>
<Ionicons name="scan" size={20} color={colors.onPrimary} />
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>
{t.onboardingScanBtn}
</Text>
</TouchableOpacity>
<View style={styles.authActions}>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.primary, backgroundColor: colors.surface }]}
onPress={() => router.push('/auth/signup')}
activeOpacity={0.82}
>
<Text style={[styles.secondaryBtnText, { color: colors.primary }]}>
{t.onboardingRegister}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => router.push('/auth/login')}
activeOpacity={0.82}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>
{t.onboardingLogin}
</Text>
</TouchableOpacity>
</View>
<Text style={[styles.disclaimer, { color: colors.textMuted }]}>
{t.onboardingDisclaimer}
</Text>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 32,
paddingTop: SCREEN_H * 0.12,
paddingBottom: 40,
},
heroSection: {
alignItems: 'center',
marginBottom: 40,
},
iconContainer: {
width: 120,
height: 120,
borderRadius: 28,
backgroundColor: '#fff',
elevation: 8,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
marginBottom: 24,
overflow: 'hidden',
},
appIcon: {
width: '100%',
height: '100%',
},
appName: {
fontSize: 40,
fontWeight: '900',
letterSpacing: -1.5,
marginBottom: 4,
},
tagline: {
fontSize: 17,
fontWeight: '500',
opacity: 0.8,
},
featuresSection: {
gap: 12,
flex: 1,
justifyContent: 'center',
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
paddingHorizontal: 20,
paddingVertical: 16,
borderRadius: 20,
borderWidth: 1,
},
featureIcon: {
width: 44,
height: 44,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
},
featureText: {
flex: 1,
fontSize: 15,
fontWeight: '600',
letterSpacing: 0.1,
},
buttonsSection: {
gap: 16,
marginTop: 20,
},
primaryBtn: {
height: 58,
borderRadius: 20,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 12,
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
primaryBtnText: {
fontSize: 17,
fontWeight: '700',
},
authActions: {
flexDirection: 'row',
gap: 12,
},
secondaryBtn: {
flex: 1,
height: 54,
borderRadius: 20,
borderWidth: 1.5,
justifyContent: 'center',
alignItems: 'center',
},
secondaryBtnText: {
fontSize: 15,
fontWeight: '600',
},
disclaimer: {
fontSize: 12,
textAlign: 'center',
opacity: 0.6,
marginTop: 8,
},
});

1443
app/plant/[id].tsx Normal file

File diff suppressed because it is too large Load Diff

657
app/profile/billing.tsx Normal file
View File

@@ -0,0 +1,657 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import RevenueCatUI, { PAYWALL_RESULT } from "react-native-purchases-ui";
import { usePostHog } from 'posthog-react-native';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types';
import { PurchaseProductId } from '../../services/backend/contracts';
const getBillingCopy = (language: Language) => {
if (language === 'de') {
return {
title: 'Abo und Credits',
planLabel: 'Aktueller Plan',
planFree: 'Free',
planPro: 'Pro',
creditsAvailableLabel: 'Verfügbare Credits',
manageSubscription: 'Abo verwalten',
subscriptionTitle: 'Abos',
subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.',
freePlanName: 'Free',
freePlanPrice: '0 EUR / Monat',
proPlanName: 'Pro',
proPlanPrice: '4.99 EUR / Monat',
proBadgeText: 'EMPFOHLEN',
proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39.99 EUR / Jahr',
proYearlyBadgeText: 'SPAREN',
proBenefits: [
'250 Credits jeden Monat',
'Pro-Scans mit GPT-5.4',
'Unbegrenzte Historie & Galerie',
'KI-Pflanzendoktor inklusive',
'Priorisierter Support'
],
topupTitle: 'Credits Aufladen',
topupSmall: '25 Credits 1,99 €',
topupMedium: '120 Credits 6,99 €',
topupLarge: '300 Credits 12,99 €',
topupBestValue: 'BESTES ANGEBOT',
cancelTitle: 'Schade, dass du gehst',
cancelQuestion: 'Dürfen wir fragen, warum du kündigst?',
reasonTooExpensive: 'Es ist mir zu teuer',
reasonNotUsing: 'Ich nutze die App zu selten',
reasonOther: 'Ein anderer Grund',
offerTitle: 'Ein Geschenk für dich!',
offerText: 'Bleib dabei und erhalte den nächsten Monat für nur 2,49 € (50% Rabatt).',
offerAccept: 'Rabatt sichern',
offerDecline: 'Nein, Kündigung fortsetzen',
confirmCancelBtn: 'Jetzt kündigen',
};
} else if (language === 'es') {
return {
title: 'Suscripción y Créditos',
planLabel: 'Plan Actual',
planFree: 'Gratis',
planPro: 'Pro',
creditsAvailableLabel: 'Créditos Disponibles',
manageSubscription: 'Administrar Suscripción',
subscriptionTitle: 'Suscripciones',
subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.',
freePlanName: 'Gratis',
freePlanPrice: '0 EUR / Mes',
proPlanName: 'Pro',
proPlanPrice: '4.99 EUR / Mes',
proBadgeText: 'RECOMENDADO',
proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39.99 EUR / Año',
proYearlyBadgeText: 'AHORRAR',
proBenefits: [
'250 créditos cada mes',
'Escaneos Pro con GPT-5.4',
'Historial y galería ilimitados',
'Doctor de plantas de IA incluido',
'Soporte prioritario'
],
topupTitle: 'Recargar Créditos',
topupSmall: '25 Créditos 1,99 €',
topupMedium: '120 Créditos 6,99 €',
topupLarge: '300 Créditos 12,99 €',
topupBestValue: 'MEJOR OFERTA',
cancelTitle: 'Lamentamos verte ir',
cancelQuestion: '¿Podemos saber por qué cancelas?',
reasonTooExpensive: 'Es muy caro',
reasonNotUsing: 'No lo uso suficiente',
reasonOther: 'Otra razón',
offerTitle: '¡Un regalo para ti!',
offerText: 'Quédate y obtén el próximo mes por solo 2,49 € (50% de descuento).',
offerAccept: 'Aceptar descuento',
offerDecline: 'No, continuar cancelando',
confirmCancelBtn: 'Cancelar ahora',
};
}
return {
title: 'Billing & Credits',
planLabel: 'Current Plan',
planFree: 'Free',
planPro: 'Pro',
creditsAvailableLabel: 'Available Credits',
manageSubscription: 'Manage Subscription',
subscriptionTitle: 'Subscriptions',
subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.',
freePlanName: 'Free',
freePlanPrice: '0 EUR / Month',
proPlanName: 'Pro',
proPlanPrice: '4.99 EUR / Month',
proBadgeText: 'RECOMMENDED',
proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39.99 EUR / Year',
proYearlyBadgeText: 'SAVE',
proBenefits: [
'250 credits every month',
'Pro scans with GPT-5.4',
'Unlimited history & gallery',
'AI Plant Doctor included',
'Priority support'
],
topupTitle: 'Topup Credits',
topupSmall: '25 Credits €1.99',
topupMedium: '120 Credits €6.99',
topupLarge: '300 Credits €12.99',
topupBestValue: 'BEST VALUE',
cancelTitle: 'Sorry to see you go',
cancelQuestion: 'May we ask why you are cancelling?',
reasonTooExpensive: 'It is too expensive',
reasonNotUsing: 'I don\'t use it enough',
reasonOther: 'Other reason',
offerTitle: 'A gift for you!',
offerText: 'Stay with us and get your next month for just €2.49 (50% off).',
offerAccept: 'Claim discount',
offerDecline: 'No, continue cancelling',
confirmCancelBtn: 'Cancel now',
};
};
export default function BillingScreen() {
const router = useRouter();
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, appearanceMode, colorPalette, session } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const copy = getBillingCopy(language);
const posthog = usePostHog();
const [subModalVisible, setSubModalVisible] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
// Cancel Flow State
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
const [cancelReason, setCancelReason] = useState<string | null>(null);
const planId = billingSummary?.entitlement?.plan || 'free';
const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--');
const handlePurchase = async (productId: PurchaseProductId) => {
setIsUpdating(true);
try {
const paywallResult: PAYWALL_RESULT = await RevenueCatUI.presentPaywall();
switch (paywallResult) {
case PAYWALL_RESULT.NOT_PRESENTED:
case PAYWALL_RESULT.ERROR:
case PAYWALL_RESULT.CANCELLED:
break;
case PAYWALL_RESULT.PURCHASED:
case PAYWALL_RESULT.RESTORED:
await simulatePurchase(productId);
setSubModalVisible(false);
break;
default:
break;
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error('Payment failed', e);
if (__DEV__ && (msg.toLowerCase().includes('native') || msg.toLowerCase().includes('not found') || msg.toLowerCase().includes('undefined'))) {
// Fallback for Expo Go since RevenueCat native module is not available
console.log('Falling back to simulated purchase in Expo Go');
await simulatePurchase(productId);
setSubModalVisible(false);
} else {
Alert.alert('Unerwarteter Fehler', msg);
}
} finally {
setIsUpdating(false);
}
};
const handleSimulatePurchase = async (productId: PurchaseProductId) => {
// Fallback for free option
setIsUpdating(true);
await simulatePurchase(productId);
setIsUpdating(false);
setSubModalVisible(false);
};
const handleDowngrade = async () => {
if (planId === 'free') return; // already on free plan
setCancelStep('survey');
};
const finalizeCancel = async () => {
setIsUpdating(true);
try {
await simulateWebhookEvent('entitlement_revoked');
setCancelStep('none');
setSubModalVisible(false);
} catch (e) {
console.error('Downgrade failed', e);
} finally {
setIsUpdating(false);
}
};
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ThemeBackdrop colors={colors} />
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
<View style={{ width: 40 }} />
</View>
<ScrollView contentContainerStyle={styles.scrollContent}>
{isLoadingBilling ? (
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} />
) : (
<>
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.planLabel}</Text>
<View style={[styles.row, { marginBottom: 16 }]}>
<Text style={[styles.value, { color: colors.text }]}>
{planId === 'pro' ? copy.planPro : copy.planFree}
</Text>
<TouchableOpacity
style={[styles.manageBtn, { backgroundColor: colors.primary }]}
onPress={() => setSubModalVisible(true)}
>
<Text style={styles.manageBtnText}>{copy.manageSubscription}</Text>
</TouchableOpacity>
</View>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.creditsAvailableLabel}</Text>
<Text style={[styles.creditsValue, { color: colors.text }]}>{credits}</Text>
</View>
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.topupTitle}</Text>
<View style={{ gap: 10, marginTop: 8 }}>
{([
{ id: 'topup_small' as PurchaseProductId, label: copy.topupSmall },
{ id: 'topup_medium' as PurchaseProductId, label: copy.topupMedium, badge: copy.topupBestValue },
{ id: 'topup_large' as PurchaseProductId, label: copy.topupLarge },
] as { id: PurchaseProductId; label: string; badge?: string }[]).map((pack) => (
<TouchableOpacity
key={pack.id}
style={[
styles.topupBtn,
{
borderColor: pack.badge ? colors.primary : colors.border,
backgroundColor: pack.badge ? colors.primary + '15' : 'transparent',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
}
]}
onPress={() => handlePurchase(pack.id)}
disabled={isUpdating}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Ionicons name="flash" size={18} color={colors.primary} />
<Text style={[styles.topupText, { color: colors.text }]}>
{isUpdating ? '...' : pack.label}
</Text>
</View>
{pack.badge && (
<View style={{ backgroundColor: colors.primary, borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 }}>
<Text style={{ color: '#fff', fontSize: 10, fontWeight: '700' }}>{pack.badge}</Text>
</View>
)}
</TouchableOpacity>
))}
</View>
</View>
</>
)}
</ScrollView>
</SafeAreaView>
<Modal visible={subModalVisible} transparent animationType="slide" onRequestClose={() => setSubModalVisible(false)}>
<View style={styles.modalOverlay}>
<View style={[styles.modalContent, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: colors.text }]}>
{cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle}
</Text>
<TouchableOpacity onPress={() => {
setSubModalVisible(false);
setCancelStep('none');
}}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
</View>
{cancelStep === 'none' ? (
<>
<Text style={[styles.modalHint, { color: colors.text + '80' }]}>{copy.subscriptionHint}</Text>
<View style={styles.plansContainer}>
<TouchableOpacity
style={[
styles.planOption,
{ borderColor: colors.border },
planId === 'free' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' }
]}
onPress={handleDowngrade}
disabled={isUpdating}
>
<View>
<Text style={[styles.planName, { color: colors.text }]}>{copy.freePlanName}</Text>
<Text style={[styles.planPrice, { color: colors.text + '80' }]}>{copy.freePlanPrice}</Text>
</View>
{planId === 'free' && <Ionicons name="checkmark-circle" size={24} color={colors.primary} />}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.planOption,
{ borderColor: colors.border },
planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' }
]}
onPress={() => handlePurchase('monthly_pro')}
disabled={isUpdating}
>
<View style={{ flex: 1 }}>
<View style={styles.planHeaderRow}>
<Text style={[styles.planName, { color: colors.text }]}>{copy.proPlanName}</Text>
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}>
<Text style={styles.proBadgeText}>{copy.proBadgeText}</Text>
</View>
</View>
<Text style={[styles.planPrice, { color: colors.text + '80' }]}>{copy.proPlanPrice}</Text>
<View style={styles.proBenefits}>
{copy.proBenefits.map((b, i) => (
<View key={i} style={styles.benefitRow}>
<Ionicons name="checkmark" size={14} color={colors.primary} />
<Text style={[styles.benefitText, { color: colors.textSecondary }]}>{b}</Text>
</View>
))}
</View>
</View>
{planId === 'pro' && <Ionicons name="checkmark-circle" size={24} color={colors.primary} />}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.planOption,
{ borderColor: colors.border },
planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' }
]}
onPress={() => handlePurchase('yearly_pro')}
disabled={isUpdating}
>
<View style={{ flex: 1 }}>
<View style={styles.planHeaderRow}>
<Text style={[styles.planName, { color: colors.text }]}>{copy.proYearlyPlanName}</Text>
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}>
<Text style={styles.proBadgeText}>{copy.proYearlyBadgeText}</Text>
</View>
</View>
<Text style={[styles.planPrice, { color: colors.text + '80' }]}>{copy.proYearlyPlanPrice}</Text>
<View style={styles.proBenefits}>
{copy.proBenefits.map((b, i) => (
<View key={i} style={styles.benefitRow}>
<Ionicons name="checkmark" size={14} color={colors.primary} />
<Text style={[styles.benefitText, { color: colors.textSecondary }]}>{b}</Text>
</View>
))}
</View>
</View>
{planId === 'pro' && <Ionicons name="checkmark-circle" size={24} color={colors.primary} />}
</TouchableOpacity>
</View>
</>
) : cancelStep === 'survey' ? (
<View style={styles.cancelFlowContainer}>
<Text style={[styles.cancelHint, { color: colors.textSecondary }]}>{copy.cancelQuestion}</Text>
<View style={styles.reasonList}>
{[
{ id: 'expensive', label: copy.reasonTooExpensive, icon: 'cash-outline' },
{ id: 'not_using', label: copy.reasonNotUsing, icon: 'calendar-outline' },
{ id: 'other', label: copy.reasonOther, icon: 'ellipsis-horizontal-outline' },
].map((reason) => (
<TouchableOpacity
key={reason.id}
style={[styles.reasonOption, { borderColor: colors.border }]}
onPress={() => {
setCancelReason(reason.id);
setCancelStep('offer');
}}
>
<View style={[styles.reasonIcon, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name={reason.icon as any} size={20} color={colors.textSecondary} />
</View>
<Text style={[styles.reasonText, { color: colors.text }]}>{reason.label}</Text>
<Ionicons name="chevron-forward" size={18} color={colors.borderStrong} />
</TouchableOpacity>
))}
</View>
</View>
) : (
<View style={styles.cancelFlowContainer}>
<View style={[styles.offerCard, { backgroundColor: colors.primarySoft }]}>
<View style={[styles.offerIconWrap, { backgroundColor: colors.primary }]}>
<Ionicons name="gift" size={28} color="#fff" />
</View>
<Text style={[styles.offerText, { color: colors.text }]}>{copy.offerText}</Text>
<TouchableOpacity
style={[styles.offerAcceptBtn, { backgroundColor: colors.primary }]}
onPress={() => {
// Handle applying discount here (future implementation)
Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)');
setCancelStep('none');
setSubModalVisible(false);
}}
>
<Text style={styles.offerAcceptBtnText}>{copy.offerAccept}</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.offerDeclineBtn}
onPress={finalizeCancel}
disabled={isUpdating}
>
<Text style={[styles.offerDeclineBtnText, { color: colors.textMuted }]}>{copy.offerDecline}</Text>
</TouchableOpacity>
</View>
)}
{isUpdating && cancelStep === 'none' && <ActivityIndicator color={colors.primary} style={{ marginTop: 16 }} />}
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', padding: 16 },
backButton: { width: 40, height: 40, justifyContent: 'center' },
title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' },
scrollContent: { padding: 16, gap: 16 },
card: {
padding: 16,
borderRadius: 16,
borderWidth: StyleSheet.hairlineWidth,
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
value: {
fontSize: 18,
fontWeight: '600',
},
manageBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
},
manageBtnText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
creditsValue: {
fontSize: 32,
fontWeight: '700',
},
topupBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 12,
borderWidth: 2,
gap: 8,
},
topupText: {
fontSize: 16,
fontWeight: '600',
},
modalOverlay: {
flex: 1,
backgroundColor: '#00000080',
justifyContent: 'flex-end',
},
modalContent: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: 24,
borderTopWidth: StyleSheet.hairlineWidth,
paddingBottom: 40,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
modalTitle: {
fontSize: 20,
fontWeight: '700',
},
modalHint: {
fontSize: 14,
marginBottom: 24,
},
plansContainer: {
gap: 12,
},
planOption: {
padding: 16,
borderRadius: 12,
borderWidth: 2,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
planName: {
fontSize: 18,
fontWeight: '600',
},
planPrice: {
fontSize: 14,
},
planHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 2,
},
proBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 6,
},
proBadgeText: {
color: '#fff',
fontSize: 10,
fontWeight: '800',
},
proBenefits: {
marginTop: 12,
gap: 6,
},
benefitRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
benefitText: {
fontSize: 12,
fontWeight: '500',
},
cancelFlowContainer: {
marginTop: 8,
},
cancelHint: {
fontSize: 15,
marginBottom: 16,
},
reasonList: {
gap: 12,
},
reasonOption: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderWidth: 1,
borderRadius: 12,
},
reasonIcon: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
reasonText: {
flex: 1,
fontSize: 16,
fontWeight: '500',
},
offerCard: {
borderRadius: 16,
padding: 24,
alignItems: 'center',
marginBottom: 16,
},
offerIconWrap: {
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
offerText: {
fontSize: 16,
textAlign: 'center',
lineHeight: 24,
marginBottom: 24,
fontWeight: '500',
},
offerAcceptBtn: {
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 24,
width: '100%',
alignItems: 'center',
},
offerAcceptBtnText: {
color: '#fff',
fontSize: 16,
fontWeight: '700',
},
offerDeclineBtn: {
paddingVertical: 12,
alignItems: 'center',
},
offerDeclineBtnText: {
fontSize: 15,
fontWeight: '500',
},
});

226
app/profile/data.tsx Normal file
View File

@@ -0,0 +1,226 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Share, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
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 getDataCopy = (language: Language) => {
if (language === 'de') {
return {
title: 'Daten & Datenschutz',
exportData: 'Daten exportieren',
exportHint: 'Teilt deine Sammlung als JSON.',
clearHistory: 'Lexikon-Verlauf loeschen',
clearHistoryHint: 'Entfernt alle letzten Suchbegriffe.',
clearHistoryDoneTitle: 'Verlauf geloescht',
clearHistoryDoneMessage: 'Der Suchverlauf wurde entfernt.',
logout: 'Abmelden',
logoutHint: 'Zurueck zum Onboarding und Profil zuruecksetzen.',
logoutConfirmTitle: 'Abmelden?',
logoutConfirmMessage: 'Du wirst auf den Startbildschirm zurueckgesetzt.',
deleteAccount: 'Konto unwiderruflich löschen',
deleteAccountHint: 'Löscht alle deine Daten, Pflanzen und Abos permanent.',
deleteConfirmTitle: 'Konto wirklich löschen?',
deleteConfirmMessage: 'Achtung: Dieser Schritt kann nicht rückgängig gemacht werden. Alle deine Pflanzen, Scans und Credits gehen sofort verloren.',
deleteActionBtn: 'Ja, dauerhaft löschen',
genericErrorTitle: 'Fehler',
genericErrorMessage: 'Aktion konnte nicht abgeschlossen werden.',
};
}
if (language === 'es') {
return {
title: 'Datos y Privacidad',
exportData: 'Exportar Datos',
exportHint: 'Comparte tu coleccion como JSON.',
clearHistory: 'Borrar historial',
clearHistoryHint: 'Elimina las busquedas recientes.',
clearHistoryDoneTitle: 'Historial borrado',
clearHistoryDoneMessage: 'El historial de busqueda ha sido eliminado.',
logout: 'Cerrar sesion',
logoutHint: 'Volver a la pantalla de inicio y reiniciar perfil.',
logoutConfirmTitle: 'Cerrar sesion?',
logoutConfirmMessage: 'Seras enviado a la pantalla de inicio.',
deleteAccount: 'Eliminar cuenta permanentemente',
deleteAccountHint: 'Elimina todos tus datos, plantas y suscripciones.',
deleteConfirmTitle: '¿Seguro que quieres eliminar tu cuenta?',
deleteConfirmMessage: 'Atención: Este paso no se puede deshacer. Todas tus plantas, escaneos y créditos se perderán inmediatamente.',
deleteActionBtn: 'Sí, eliminar permanentemente',
genericErrorTitle: 'Error',
genericErrorMessage: 'La accion no pudo ser completada.',
};
}
return {
title: 'Data & Privacy',
exportData: 'Export Data',
exportHint: 'Share your collection as JSON.',
clearHistory: 'Clear Search History',
clearHistoryHint: 'Removes recent search queries.',
clearHistoryDoneTitle: 'History Cleared',
clearHistoryDoneMessage: 'Search history has been removed.',
logout: 'Log Out',
logoutHint: 'Return to onboarding and reset profile.',
logoutConfirmTitle: 'Log Out?',
logoutConfirmMessage: 'You will be returned to the start screen.',
deleteAccount: 'Delete Account Permanently',
deleteAccountHint: 'Permanently deletes all your data, plants, and subscriptions.',
deleteConfirmTitle: 'Are you sure?',
deleteConfirmMessage: 'Warning: This cannot be undone. All your plants, scans, and credits will be lost immediately.',
deleteActionBtn: 'Yes, delete permanently',
genericErrorTitle: 'Error',
genericErrorMessage: 'Action could not be completed.',
};
};
export default function DataScreen() {
const router = useRouter();
const { isDarkMode, language, plants, appearanceMode, colorPalette, clearLexiconSearchHistory, signOut } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const copy = getDataCopy(language);
const handleExportData = async () => {
try {
const dataStr = JSON.stringify(plants, null, 2);
await Share.share({
message: dataStr,
title: 'GreenLens_Export.json',
});
} catch {
Alert.alert(copy.genericErrorTitle, copy.genericErrorMessage);
}
};
const handleClearHistory = () => {
clearLexiconSearchHistory();
Alert.alert(copy.clearHistoryDoneTitle, copy.clearHistoryDoneMessage);
};
const handleLogout = () => {
Alert.alert(copy.logoutConfirmTitle, copy.logoutConfirmMessage, [
{ text: 'Cancel', style: 'cancel' },
{
text: copy.logout,
style: 'destructive',
onPress: async () => {
await signOut();
router.replace('/auth/login');
},
},
]);
};
const handleDeleteAccount = () => {
Alert.alert(copy.deleteConfirmTitle, copy.deleteConfirmMessage, [
{ text: 'Cancel', style: 'cancel' },
{
text: copy.deleteActionBtn,
style: 'destructive',
onPress: async () => {
// Future implementation: call backend to wipe user data, cancel active Stripe subscriptions
await signOut();
router.replace('/onboarding');
},
},
]);
};
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ThemeBackdrop colors={colors} />
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
<View style={{ width: 40 }} />
</View>
<ScrollView contentContainerStyle={styles.scrollContent}>
<TouchableOpacity style={[styles.actionRow, { backgroundColor: colors.cardBg, borderColor: colors.border }]} onPress={handleExportData}>
<View style={styles.actionIcon}>
<Ionicons name="download-outline" size={24} color={colors.text} />
</View>
<View style={styles.actionTextContainer}>
<Text style={[styles.actionTitle, { color: colors.text }]}>{copy.exportData}</Text>
<Text style={[styles.actionHint, { color: `${colors.text}80` }]}>{copy.exportHint}</Text>
</View>
</TouchableOpacity>
<TouchableOpacity style={[styles.actionRow, { backgroundColor: colors.cardBg, borderColor: colors.border }]} onPress={handleClearHistory}>
<View style={styles.actionIcon}>
<Ionicons name="time-outline" size={24} color={colors.text} />
</View>
<View style={styles.actionTextContainer}>
<Text style={[styles.actionTitle, { color: colors.text }]}>{copy.clearHistory}</Text>
<Text style={[styles.actionHint, { color: `${colors.text}80` }]}>{copy.clearHistoryHint}</Text>
</View>
</TouchableOpacity>
<TouchableOpacity style={[styles.actionRow, { backgroundColor: colors.cardBg, borderColor: colors.border }]} onPress={handleLogout}>
<View style={styles.actionIcon}>
<Ionicons name="log-out-outline" size={24} color={colors.text} />
</View>
<View style={styles.actionTextContainer}>
<Text style={[styles.actionTitle, { color: colors.text }]}>{copy.logout}</Text>
<Text style={[styles.actionHint, { color: `${colors.text}80` }]}>{copy.logoutHint}</Text>
</View>
</TouchableOpacity>
<View style={{ marginTop: 24 }}>
<TouchableOpacity style={[styles.actionRow, { backgroundColor: '#FF3B3015', borderColor: '#FF3B3050', marginBottom: 0 }]} onPress={handleDeleteAccount}>
<View style={[styles.actionIcon, { backgroundColor: '#FF3B3020' }]}>
<Ionicons name="trash-bin-outline" size={24} color="#FF3B30" />
</View>
<View style={styles.actionTextContainer}>
<Text style={[styles.actionTitle, { color: '#FF3B30' }]}>{copy.deleteAccount}</Text>
<Text style={[styles.actionHint, { color: '#FF3B3080' }]}>{copy.deleteAccountHint}</Text>
</View>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', padding: 16 },
backButton: { width: 40, height: 40, justifyContent: 'center' },
title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' },
scrollContent: { padding: 16, gap: 16 },
actionRow: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 16,
borderWidth: StyleSheet.hairlineWidth,
marginBottom: 16,
},
actionIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#00000010',
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
},
actionTextContainer: {
flex: 1,
},
actionTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
actionHint: {
fontSize: 13,
},
});

229
app/profile/preferences.tsx Normal file
View File

@@ -0,0 +1,229 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { AppearanceMode, ColorPalette, Language } from '../../types';
const PALETTE_SWATCHES: Record<ColorPalette, string[]> = {
forest: ['#5fa779', '#3d7f57'],
ocean: ['#5a90be', '#3d6f99'],
sunset: ['#c98965', '#a36442'],
mono: ['#7b8796', '#5b6574'],
};
const getPreferencesCopy = (language: Language) => {
if (language === 'de') {
return {
title: 'Einstellungen',
appearanceMode: 'Modell',
colorPalette: 'Farbpalette',
languageLabel: 'Sprache',
themeSystem: 'System',
themeLight: 'Hell',
themeDark: 'Dunkel',
paletteForest: 'Forest',
paletteOcean: 'Ocean',
paletteSunset: 'Sunset',
paletteMono: 'Mono',
};
} else if (language === 'es') {
return {
title: 'Ajustes',
appearanceMode: 'Modo',
colorPalette: 'Paleta',
languageLabel: 'Idioma',
themeSystem: 'Sistema',
themeLight: 'Claro',
themeDark: 'Oscuro',
paletteForest: 'Forest',
paletteOcean: 'Ocean',
paletteSunset: 'Sunset',
paletteMono: 'Mono',
};
}
return {
title: 'Preferences',
appearanceMode: 'Appearance Mode',
colorPalette: 'Color Palette',
languageLabel: 'Language',
themeSystem: 'System',
themeLight: 'Light',
themeDark: 'Dark',
paletteForest: 'Forest',
paletteOcean: 'Ocean',
paletteSunset: 'Sunset',
paletteMono: 'Mono',
};
};
export default function PreferencesScreen() {
const router = useRouter();
const { isDarkMode, appearanceMode, colorPalette, language, setAppearanceMode, setColorPalette, changeLanguage } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const copy = getPreferencesCopy(language);
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ThemeBackdrop colors={colors} />
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
<View style={{ width: 40 }} />
</View>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.appearanceMode}</Text>
<View style={styles.segmentedControl}>
{(['system', 'light', 'dark'] as AppearanceMode[]).map((mode) => {
const isActive = appearanceMode === mode;
const label = mode === 'system' ? copy.themeSystem : mode === 'light' ? copy.themeLight : copy.themeDark;
return (
<TouchableOpacity
key={mode}
style={[
styles.segmentBtn,
isActive && { backgroundColor: colors.primary },
]}
onPress={() => setAppearanceMode(mode)}
>
<Text style={[
styles.segmentText,
{ color: isActive ? '#fff' : colors.text }
]}>
{label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.colorPalette}</Text>
<View style={styles.swatchContainer}>
{(['forest', 'ocean', 'sunset', 'mono'] as ColorPalette[]).map((p) => {
const isActive = colorPalette === p;
const swatch = PALETTE_SWATCHES[p] || ['#ccc', '#999'];
const label = p === 'forest' ? copy.paletteForest : p === 'ocean' ? copy.paletteOcean : p === 'sunset' ? copy.paletteSunset : copy.paletteMono;
return (
<TouchableOpacity
key={p}
style={[
styles.swatchWrap,
isActive && { borderColor: colors.primary, borderWidth: 2 }
]}
onPress={() => setColorPalette(p)}
>
<View style={[styles.swatch, { backgroundColor: swatch[0] }]} />
<Text style={[styles.swatchLabel, { color: colors.text }]}>{label}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.languageLabel}</Text>
<View style={styles.langRow}>
{(['en', 'de', 'es'] as Language[]).map(lang => {
const isActive = language === lang;
const label = lang === 'en' ? 'English' : lang === 'de' ? 'Deutsch' : 'Español';
return (
<TouchableOpacity
key={lang}
style={[
styles.langBtn,
isActive && { backgroundColor: colors.primary }
]}
onPress={() => changeLanguage(lang)}
>
<Text style={isActive ? { color: '#fff', fontWeight: '600' } : { color: colors.text }}>
{label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
</ScrollView>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', padding: 16 },
backButton: { width: 40, height: 40, justifyContent: 'center' },
title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' },
scrollContent: { padding: 16, gap: 16 },
card: {
padding: 16,
borderRadius: 16,
borderWidth: StyleSheet.hairlineWidth,
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 16,
},
segmentedControl: {
flexDirection: 'row',
backgroundColor: '#00000010',
borderRadius: 12,
padding: 4,
},
segmentBtn: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
borderRadius: 8,
},
segmentText: {
fontSize: 14,
fontWeight: '500',
},
swatchContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
},
swatchWrap: {
alignItems: 'center',
padding: 4,
borderRadius: 12,
borderWidth: 2,
borderColor: 'transparent',
gap: 6,
},
swatch: {
width: 48,
height: 48,
borderRadius: 24,
},
swatchLabel: {
fontSize: 12,
fontWeight: '500',
},
langRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
langBtn: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 12,
backgroundColor: '#00000010',
},
});

669
app/scanner.tsx Normal file
View File

@@ -0,0 +1,669 @@
import React, { useEffect, useRef, useState } from 'react';
import {
View, Text, StyleSheet, TouchableOpacity, Image, Alert, Animated, Easing,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as ImagePicker from 'expo-image-picker';
import * as Haptics from 'expo-haptics';
import { usePostHog } from 'posthog-react-native';
import { useApp } from '../context/AppContext';
import { useColors } from '../constants/Colors';
import { PlantRecognitionService } from '../services/plantRecognitionService';
import { IdentificationResult } from '../types';
import { ResultCard } from '../components/ResultCard';
import { backendApiClient, isInsufficientCreditsError } from '../services/backend/backendApiClient';
import { isBackendApiError } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency';
const HEALTH_CHECK_CREDIT_COST = 2;
const getBillingCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
creditsLabel: 'Credits',
noCreditsTitle: 'Keine Credits mehr',
noCreditsMessage: 'Du hast keine Credits mehr fuer KI-Scans. Upgrade oder Top-up im Profil.',
healthNoCreditsMessage: `Du brauchst ${HEALTH_CHECK_CREDIT_COST} Credits fuer den Health-Check.`,
managePlan: 'Plan verwalten',
dismiss: 'Schliessen',
genericErrorTitle: 'Fehler',
genericErrorMessage: 'Analyse fehlgeschlagen.',
providerErrorMessage: 'KI-Scan gerade nicht verfuegbar. Bitte API-Key/Netzwerk pruefen.',
healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfuegbar. Bitte API-Key/Netzwerk pruefen.',
healthTitle: 'Health Check',
healthDoneTitle: 'Health Check abgeschlossen',
healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.',
signupLabel: 'Registrieren',
};
}
if (language === 'es') {
return {
creditsLabel: 'Creditos',
noCreditsTitle: 'Sin creditos',
noCreditsMessage: 'No tienes creditos para escaneos AI. Actualiza o compra top-up en Perfil.',
healthNoCreditsMessage: `Necesitas ${HEALTH_CHECK_CREDIT_COST} creditos para el health-check.`,
managePlan: 'Gestionar plan',
dismiss: 'Cerrar',
genericErrorTitle: 'Error',
genericErrorMessage: 'Analisis fallido.',
providerErrorMessage: 'Escaneo IA no disponible ahora. Revisa API key y red.',
healthProviderErrorMessage: 'Health-check IA no disponible ahora. Revisa API key y red.',
healthTitle: 'Health Check',
healthDoneTitle: 'Health-check completado',
healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.',
signupLabel: 'Registrarse',
};
}
return {
creditsLabel: 'Credits',
noCreditsTitle: 'No credits left',
noCreditsMessage: 'You have no AI scan credits left. Upgrade or buy a top-up in Profile.',
healthNoCreditsMessage: `You need ${HEALTH_CHECK_CREDIT_COST} credits for the health check.`,
managePlan: 'Manage plan',
dismiss: 'Close',
genericErrorTitle: 'Error',
genericErrorMessage: 'Analysis failed.',
providerErrorMessage: 'AI scan is unavailable right now. Check API key and network.',
healthProviderErrorMessage: 'AI health check is unavailable right now. Check API key and network.',
healthTitle: 'Health Check',
healthDoneTitle: 'Health Check Complete',
healthDoneMessage: 'The new photo was analyzed and added to gallery.',
signupLabel: 'Sign Up',
};
};
export default function ScannerScreen() {
const params = useLocalSearchParams<{ mode?: string; plantId?: string }>();
const posthog = usePostHog();
const {
isDarkMode,
colorPalette,
language,
t,
savePlant,
plants,
updatePlant,
billingSummary,
refreshBillingSummary,
isLoadingBilling,
session,
setPendingPlant,
guestScanCount,
incrementGuestScanCount,
} = useApp();
const colors = useColors(isDarkMode, colorPalette);
const router = useRouter();
const insets = useSafeAreaInsets();
const billingCopy = getBillingCopy(language);
const isHealthMode = params.mode === 'health';
const healthPlantId = Array.isArray(params.plantId) ? params.plantId[0] : params.plantId;
const healthPlant = isHealthMode && healthPlantId
? plants.find((item) => item.id === healthPlantId)
: null;
const availableCredits = session
? (billingSummary?.credits.available ?? 0)
: Math.max(0, 5 - guestScanCount);
const [permission, requestPermission] = useCameraPermissions();
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
const [analysisResult, setAnalysisResult] = useState<IdentificationResult | null>(null);
const cameraRef = useRef<CameraView>(null);
const scanLineProgress = useRef(new Animated.Value(0)).current;
const scanPulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (!isAnalyzing) {
scanLineProgress.stopAnimation();
scanLineProgress.setValue(0);
scanPulse.stopAnimation();
scanPulse.setValue(0);
return;
}
const lineAnimation = Animated.loop(
Animated.sequence([
Animated.timing(scanLineProgress, {
toValue: 1,
duration: 1500,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(scanLineProgress, {
toValue: 0,
duration: 1500,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
])
);
const pulseAnimation = Animated.loop(
Animated.sequence([
Animated.timing(scanPulse, { toValue: 1, duration: 900, useNativeDriver: true }),
Animated.timing(scanPulse, { toValue: 0, duration: 900, useNativeDriver: true }),
])
);
lineAnimation.start();
pulseAnimation.start();
return () => {
lineAnimation.stop();
pulseAnimation.stop();
};
}, [isAnalyzing, scanLineProgress, scanPulse]);
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
if (isAnalyzing) return;
if (availableCredits <= 0) {
Alert.alert(
billingCopy.noCreditsTitle,
isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage,
[
{ text: billingCopy.dismiss, style: 'cancel' },
{
text: billingCopy.signupLabel,
onPress: () => router.push('/auth/signup'),
},
],
);
return;
}
setIsAnalyzing(true);
setAnalysisProgress(0);
setAnalysisResult(null);
const startTime = Date.now();
const progressInterval = setInterval(() => {
setAnalysisProgress((prev) => {
if (prev < 30) return prev + Math.random() * 8;
if (prev < 70) return prev + Math.random() * 2;
if (prev < 90) return prev + 0.5;
return prev;
});
}, 150);
try {
if (isHealthMode) {
if (!healthPlant) {
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
setSelectedImage(null);
setIsAnalyzing(false);
return;
}
const response = await backendApiClient.runHealthCheck({
idempotencyKey: createIdempotencyKey('health-check', healthPlant.id),
imageUri,
language,
plantContext: {
name: healthPlant.name,
botanicalName: healthPlant.botanicalName,
careInfo: healthPlant.careInfo,
description: healthPlant.description,
},
});
posthog.capture('llm_generation', {
scan_type: 'health_check',
success: true,
latency_ms: Date.now() - startTime,
});
if (!session) {
incrementGuestScanCount();
}
const currentGallery = healthPlant.gallery || [];
const existingChecks = healthPlant.healthChecks || [];
const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6);
const updatedPlant = {
...healthPlant,
gallery: galleryImageUri ? [...currentGallery, galleryImageUri] : currentGallery,
healthChecks: updatedChecks,
};
await updatePlant(updatedPlant);
} else {
const result = await PlantRecognitionService.identify(imageUri, language, {
idempotencyKey: createIdempotencyKey('scan-plant'),
});
posthog.capture('llm_generation', {
scan_type: 'identification',
success: true,
latency_ms: Date.now() - startTime,
});
if (!session) {
incrementGuestScanCount();
}
setAnalysisResult(result);
}
setAnalysisProgress(100);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await new Promise(resolve => setTimeout(resolve, 500));
setIsAnalyzing(false);
if (isHealthMode && healthPlant) {
Alert.alert(billingCopy.healthDoneTitle, billingCopy.healthDoneMessage, [
{ text: billingCopy.dismiss, onPress: () => router.replace(`/plant/${healthPlant.id}`) },
]);
}
} catch (error) {
console.error('Analysis failed', error);
posthog.capture('llm_generation', {
scan_type: isHealthMode ? 'health_check' : 'identification',
success: false,
error_type: isInsufficientCreditsError(error) ? 'insufficient_credits' : 'provider_error',
latency_ms: Date.now() - startTime,
});
if (isInsufficientCreditsError(error)) {
Alert.alert(
billingCopy.noCreditsTitle,
isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage,
[
{ text: billingCopy.dismiss, style: 'cancel' },
{
text: billingCopy.managePlan,
onPress: () => router.replace('/(tabs)/profile'),
},
],
);
} else if (
isBackendApiError(error) &&
(error.code === 'PROVIDER_ERROR' || error.code === 'TIMEOUT')
) {
Alert.alert(
billingCopy.genericErrorTitle,
isHealthMode ? billingCopy.healthProviderErrorMessage : billingCopy.providerErrorMessage,
);
} else {
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
}
setSelectedImage(null);
setIsAnalyzing(false);
} finally {
clearInterval(progressInterval);
await refreshBillingSummary();
}
};
const takePicture = async () => {
if (!cameraRef.current || isAnalyzing) return;
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.7 });
if (photo) {
const analysisUri = photo.base64
? `data:image/jpeg;base64,${photo.base64}`
: photo.uri;
const galleryUri = photo.uri || analysisUri;
setSelectedImage(analysisUri);
analyzeImage(analysisUri, galleryUri);
}
};
const pickImage = async () => {
if (isAnalyzing) return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.7,
base64: true,
});
if (!result.canceled && result.assets[0]) {
const asset = result.assets[0];
const uri = asset.base64
? `data:image/jpeg;base64,${asset.base64}`
: asset.uri;
setSelectedImage(uri);
analyzeImage(uri, asset.uri || uri);
}
};
const handleSave = async () => {
if (analysisResult && selectedImage) {
if (!session) {
// Guest mode: store result and go to signup
setPendingPlant(analysisResult, selectedImage);
router.replace('/auth/signup');
return;
}
try {
await savePlant(analysisResult, selectedImage);
router.back();
} catch (error) {
console.error('Saving identified plant failed', error);
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
}
}
};
const handleClose = () => {
router.back();
};
const controlsPaddingBottom = Math.max(20, insets.bottom + 10);
const controlsPanelHeight = 28 + 80 + controlsPaddingBottom;
const analysisBottomOffset = controlsPanelHeight + 12;
const scanLineTranslateY = scanLineProgress.interpolate({
inputRange: [0, 1],
outputRange: [24, 280],
});
const scanPulseScale = scanPulse.interpolate({
inputRange: [0, 1],
outputRange: [0.98, 1.02],
});
const scanPulseOpacity = scanPulse.interpolate({
inputRange: [0, 1],
outputRange: [0.22, 0.55],
});
// Show result
if (!isHealthMode && analysisResult && selectedImage) {
return (
<ResultCard
result={analysisResult}
imageUri={selectedImage}
onSave={handleSave}
onClose={handleClose}
t={t}
isDark={isDarkMode}
colorPalette={colorPalette}
isGuest={!session}
/>
);
}
// Camera permission
if (!permission?.granted) {
return (
<View style={[styles.permissionContainer, { backgroundColor: colors.surface }]}>
<Ionicons name="camera-outline" size={48} color={colors.text} style={{ marginBottom: 16 }} />
<Text style={[styles.permissionText, { color: colors.text }]}>Camera access is required to scan plants.</Text>
<TouchableOpacity style={[styles.permissionBtn, { backgroundColor: colors.primary }]} onPress={requestPermission}>
<Text style={[styles.permissionBtnText, { color: colors.onPrimary }]}>Grant Permission</Text>
</TouchableOpacity>
<TouchableOpacity style={{ marginTop: 16 }} onPress={handleClose}>
<Text style={{ color: colors.textMuted }}>Cancel</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colors.surface }]}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={handleClose}>
<Ionicons name="close" size={28} color={colors.iconOnImage} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: colors.iconOnImage }]}>
{isHealthMode ? billingCopy.healthTitle : t.scanner}
</Text>
<View style={[styles.creditBadge, { backgroundColor: colors.heroButton, borderColor: colors.heroButtonBorder }]}>
<Ionicons name="wallet-outline" size={12} color={colors.text} />
<Text style={[styles.creditBadgeText, { color: colors.text }]}>
{billingCopy.creditsLabel}: {availableCredits}
</Text>
</View>
</View>
{/* Camera */}
<View style={styles.cameraContainer}>
{selectedImage ? (
<Image source={{ uri: selectedImage }} style={StyleSheet.absoluteFillObject} blurRadius={4} />
) : (
<CameraView ref={cameraRef} style={StyleSheet.absoluteFillObject} facing="back" />
)}
{/* Scan Frame */}
<View style={[styles.scanFrame, { borderColor: colors.heroButtonBorder }]}>
{selectedImage && (
<Image source={{ uri: selectedImage }} style={StyleSheet.absoluteFillObject} resizeMode="cover" />
)}
{isAnalyzing && (
<>
<Animated.View
pointerEvents="none"
style={[
styles.scanPulseFrame,
{
borderColor: colors.heroButton,
transform: [{ scale: scanPulseScale }],
opacity: scanPulseOpacity,
},
]}
/>
<Animated.View
pointerEvents="none"
style={[
styles.scanLine,
{
backgroundColor: colors.heroButton,
transform: [{ translateY: scanLineTranslateY }],
},
]}
/>
</>
)}
<View style={[styles.corner, styles.tl, { borderColor: colors.iconOnImage }]} />
<View style={[styles.corner, styles.tr, { borderColor: colors.iconOnImage }]} />
<View style={[styles.corner, styles.bl, { borderColor: colors.iconOnImage }]} />
<View style={[styles.corner, styles.br, { borderColor: colors.iconOnImage }]} />
</View>
</View>
{/* Analyzing Overlay */}
{isAnalyzing && (
<View
style={[
styles.analysisSheet,
{
backgroundColor: colors.overlayStrong,
borderColor: colors.heroButtonBorder,
bottom: analysisBottomOffset,
},
]}
>
<View style={styles.analysisHeader}>
<View style={[styles.analysisBadge, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="sparkles-outline" size={12} color={colors.text} />
<Text style={[styles.analysisLabel, { color: colors.text }]}>
{analysisProgress < 100 ? t.analyzing : t.result}
</Text>
</View>
<Text style={[styles.analysisPercent, { color: colors.textSecondary }]}>
{Math.round(analysisProgress)}%
</Text>
</View>
<View style={[styles.progressBg, { backgroundColor: colors.surfaceMuted }]}>
<View style={[styles.progressFill, { width: `${analysisProgress}%`, backgroundColor: colors.primary }]} />
</View>
<View style={styles.analysisFooter}>
<View style={styles.analysisStatusRow}>
<View style={[styles.statusDot, { backgroundColor: analysisProgress < 100 ? colors.warning : colors.success }]} />
<Text style={[styles.analysisStage, { color: colors.textMuted }]}>{t.localProcessing}</Text>
</View>
<Text style={[styles.analysisStageDetail, { color: colors.textSecondary }]}>
{analysisProgress < 30 ? t.scanStage1 : analysisProgress < 75 ? t.scanStage2 : t.scanStage3}
</Text>
</View>
</View>
)}
{/* Bottom Controls */}
<View
style={[
styles.controls,
{
backgroundColor: colors.background,
paddingBottom: controlsPaddingBottom,
},
]}
>
<TouchableOpacity style={[styles.controlBtn, isAnalyzing && styles.controlBtnDisabled]} onPress={pickImage} disabled={isAnalyzing}>
<Ionicons name="images-outline" size={24} color={colors.textSecondary} />
<Text style={[styles.controlLabel, { color: colors.textMuted }]}>{t.gallery}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.shutterBtn,
{ backgroundColor: colors.primary, borderColor: colors.borderStrong },
isAnalyzing && styles.shutterBtnDisabled,
]}
onPress={takePicture}
disabled={isAnalyzing}
activeOpacity={0.7}
>
<View style={[styles.shutterInner, { backgroundColor: colors.primarySoft }]} />
</TouchableOpacity>
<TouchableOpacity style={[styles.controlBtn, isAnalyzing && styles.controlBtnDisabled]} disabled={isAnalyzing}>
<Ionicons name="help-circle-outline" size={24} color={colors.textMuted} />
<Text style={[styles.controlLabel, { color: colors.textMuted }]}>{t.help}</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
header: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 60,
paddingHorizontal: 24,
},
headerTitle: { fontSize: 18, fontWeight: '600' },
creditBadge: {
borderWidth: 1,
borderRadius: 14,
paddingHorizontal: 8,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
creditBadgeText: { fontSize: 10, fontWeight: '700' },
cameraContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
scanFrame: {
width: 256,
height: 320,
borderWidth: 2.5,
borderColor: '#ffffff50',
borderRadius: 28,
overflow: 'hidden',
},
scanPulseFrame: {
...StyleSheet.absoluteFillObject,
borderWidth: 1.5,
borderRadius: 28,
},
scanLine: {
position: 'absolute',
left: 16,
right: 16,
height: 2,
borderRadius: 999,
shadowColor: '#ffffff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 8,
elevation: 6,
},
corner: { position: 'absolute', width: 24, height: 24 },
tl: { top: 16, left: 16, borderTopWidth: 4, borderLeftWidth: 4, borderTopLeftRadius: 12 },
tr: { top: 16, right: 16, borderTopWidth: 4, borderRightWidth: 4, borderTopRightRadius: 12 },
bl: { bottom: 16, left: 16, borderBottomWidth: 4, borderLeftWidth: 4, borderBottomLeftRadius: 12 },
br: { bottom: 16, right: 16, borderBottomWidth: 4, borderRightWidth: 4, borderBottomRightRadius: 12 },
controls: {
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
paddingHorizontal: 32,
paddingTop: 28,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
controlBtn: { alignItems: 'center', gap: 6 },
controlBtnDisabled: { opacity: 0.5 },
controlLabel: { fontSize: 11, fontWeight: '500' },
shutterBtn: {
width: 80,
height: 80,
borderRadius: 40,
borderWidth: 4,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
},
shutterInner: { width: 64, height: 64, borderRadius: 32 },
shutterBtnDisabled: { opacity: 0.6 },
analysisSheet: {
position: 'absolute',
left: 16,
right: 16,
borderRadius: 20,
borderWidth: 1,
paddingHorizontal: 16,
paddingVertical: 14,
zIndex: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.28,
shadowRadius: 14,
elevation: 14,
},
analysisHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
analysisBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 5,
},
analysisLabel: { fontWeight: '700', fontSize: 12, letterSpacing: 0.2 },
analysisPercent: { fontFamily: 'monospace', fontSize: 12, fontWeight: '700' },
progressBg: { height: 9, borderRadius: 999, overflow: 'hidden', marginBottom: 10 },
progressFill: { height: '100%', borderRadius: 4 },
analysisFooter: { gap: 4 },
analysisStatusRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
statusDot: { width: 8, height: 8, borderRadius: 4 },
analysisStage: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1 },
analysisStageDetail: { fontSize: 11, lineHeight: 16, fontWeight: '500' },
permissionContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 },
permissionText: { fontSize: 16, textAlign: 'center', marginBottom: 20 },
permissionBtn: { paddingHorizontal: 24, paddingVertical: 12, borderRadius: 12 },
permissionBtnText: { fontWeight: '700', fontSize: 15 },
});