Initial commit for Greenlens
This commit is contained in:
58
app/(tabs)/_layout.tsx
Normal file
58
app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { useColors } from '../../constants/Colors';
|
||||
|
||||
export default function TabLayout() {
|
||||
const { isDarkMode, colorPalette, t } = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: colors.primary,
|
||||
tabBarInactiveTintColor: colors.textMuted,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.tabBarBg,
|
||||
borderTopColor: colors.tabBarBorder,
|
||||
height: 85,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 28,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: t.tabPlants,
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="leaf-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="search"
|
||||
options={{
|
||||
title: t.tabSearch,
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="search-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: t.tabProfile,
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="person-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
846
app/(tabs)/index.tsx
Normal file
846
app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,846 @@
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { useColors } from '../../constants/Colors';
|
||||
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
||||
import { SafeImage } from '../../components/SafeImage';
|
||||
import { Plant } from '../../types';
|
||||
import { useCoachMarks } from '../../context/CoachMarksContext';
|
||||
|
||||
const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window');
|
||||
|
||||
type FilterKey = 'all' | 'today' | 'week' | 'healthy' | 'dormant';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const CONTENT_BOTTOM_PADDING = 12;
|
||||
const FAB_BOTTOM_OFFSET = 16;
|
||||
|
||||
function OnboardingChecklist({ plantsCount, colors, router, t }: { plantsCount: number; colors: any; router: any; t: any }) {
|
||||
const checklist = [
|
||||
{ id: 'scan', label: t.stepScan, completed: plantsCount > 0, icon: 'camera-outline', route: '/scanner' },
|
||||
{ id: 'lexicon', label: t.stepLexicon, completed: false, icon: 'search-outline', route: '/lexicon' },
|
||||
{ id: 'theme', label: t.stepTheme, completed: false, icon: 'color-palette-outline', route: '/profile/preferences' },
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={[styles.checklistCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Text style={[styles.checklistTitle, { color: colors.text }]}>{t.nextStepsTitle}</Text>
|
||||
<View style={styles.checklistGrid}>
|
||||
{checklist.map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={styles.checklistItem}
|
||||
onPress={() => {
|
||||
if (item.id === 'theme') {
|
||||
router.push('/profile/preferences');
|
||||
} else if (item.id === 'scan') {
|
||||
router.push('/scanner');
|
||||
} else if (item.id === 'lexicon') {
|
||||
router.push('/lexicon');
|
||||
} else {
|
||||
router.push(item.route);
|
||||
}
|
||||
}}
|
||||
disabled={item.completed}
|
||||
>
|
||||
<View style={[styles.checkIcon, { backgroundColor: item.completed ? colors.successSoft : colors.surfaceMuted }]}>
|
||||
<Ionicons
|
||||
name={item.completed ? 'checkmark-circle' : item.icon as any}
|
||||
size={18}
|
||||
color={item.completed ? colors.success : colors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.checklistText,
|
||||
{
|
||||
color: item.completed ? colors.textMuted : colors.text,
|
||||
textDecorationLine: item.completed ? 'line-through' : 'none',
|
||||
},
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{!item.completed && <Ionicons name="chevron-forward" size={12} color={colors.textMuted} />}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const getDaysUntilWatering = (plant: Plant): number => {
|
||||
const lastWateredTs = new Date(plant.lastWatered).getTime();
|
||||
if (Number.isNaN(lastWateredTs)) return 0;
|
||||
|
||||
const dueTs = lastWateredTs + (plant.careInfo.waterIntervalDays * DAY_MS);
|
||||
const remainingMs = dueTs - Date.now();
|
||||
if (remainingMs <= 0) return 0;
|
||||
|
||||
return Math.ceil(remainingMs / DAY_MS);
|
||||
};
|
||||
|
||||
export default function HomeScreen() {
|
||||
const {
|
||||
plants,
|
||||
isLoadingPlants,
|
||||
profileImageUri,
|
||||
profileName,
|
||||
billingSummary,
|
||||
isLoadingBilling,
|
||||
language,
|
||||
t,
|
||||
isDarkMode,
|
||||
colorPalette,
|
||||
} = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [activeFilter, setActiveFilter] = useState<FilterKey>('all');
|
||||
const { registerLayout, startTour } = useCoachMarks();
|
||||
const fabRef = useRef<View>(null);
|
||||
|
||||
// Tour nach Registrierung starten
|
||||
useEffect(() => {
|
||||
const checkTour = async () => {
|
||||
const flag = await AsyncStorage.getItem('greenlens_show_tour');
|
||||
if (flag !== 'true') return;
|
||||
await AsyncStorage.removeItem('greenlens_show_tour');
|
||||
|
||||
// 1 Sekunde warten, dann Tour starten
|
||||
setTimeout(() => {
|
||||
// Tab-Positionen approximieren (gleichmäßig verteilt)
|
||||
const tabBarBottom = SCREEN_H - 85;
|
||||
const tabW = SCREEN_W / 3;
|
||||
registerLayout('tab_search', { x: tabW, y: tabBarBottom + 8, width: tabW, height: 52 });
|
||||
registerLayout('tab_profile', { x: tabW * 2, y: tabBarBottom + 8, width: tabW, height: 52 });
|
||||
|
||||
startTour([
|
||||
{
|
||||
elementKey: 'fab',
|
||||
title: t.tourFabTitle,
|
||||
description: t.tourFabDesc,
|
||||
tooltipSide: 'above',
|
||||
},
|
||||
{
|
||||
elementKey: 'tab_search',
|
||||
title: t.tourSearchTitle,
|
||||
description: t.tourSearchDesc,
|
||||
tooltipSide: 'above',
|
||||
},
|
||||
{
|
||||
elementKey: 'tab_profile',
|
||||
title: t.tourProfileTitle,
|
||||
description: t.tourProfileDesc,
|
||||
tooltipSide: 'above',
|
||||
},
|
||||
]);
|
||||
}, 1000);
|
||||
};
|
||||
checkTour();
|
||||
}, []);
|
||||
|
||||
const copy = t;
|
||||
const greetingText = useMemo(() => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return copy.greetingMorning;
|
||||
if (hour < 18) return copy.greetingAfternoon;
|
||||
return copy.greetingEvening;
|
||||
}, [copy.greetingAfternoon, copy.greetingEvening, copy.greetingMorning]);
|
||||
const creditsText = useMemo(() => {
|
||||
if (isLoadingBilling && !billingSummary) {
|
||||
return '...';
|
||||
}
|
||||
if (!billingSummary) {
|
||||
return `-- ${copy.creditsLabel}`;
|
||||
}
|
||||
return `${billingSummary.credits.available} ${copy.creditsLabel}`;
|
||||
}, [billingSummary, copy.creditsLabel, isLoadingBilling]);
|
||||
|
||||
const thirstyCount = useMemo(
|
||||
() => plants.filter(plant => getDaysUntilWatering(plant) === 0).length,
|
||||
[plants]
|
||||
);
|
||||
const dueTodayPlants = useMemo(
|
||||
() => plants.filter(plant => getDaysUntilWatering(plant) === 0),
|
||||
[plants]
|
||||
);
|
||||
|
||||
const filteredPlants = useMemo(() => {
|
||||
return plants.filter((plant) => {
|
||||
if (activeFilter === 'all') return true;
|
||||
|
||||
const daysUntil = getDaysUntilWatering(plant);
|
||||
|
||||
if (activeFilter === 'today') return daysUntil === 0;
|
||||
if (activeFilter === 'week') return daysUntil <= 7;
|
||||
if (activeFilter === 'healthy') return daysUntil >= 2;
|
||||
return plant.careInfo.waterIntervalDays >= 14;
|
||||
});
|
||||
}, [plants, activeFilter]);
|
||||
|
||||
const chips: Array<{ key: FilterKey; label: string }> = [
|
||||
{ key: 'all', label: copy.all },
|
||||
{ key: 'today', label: copy.today },
|
||||
{ key: 'week', label: copy.week },
|
||||
{ key: 'healthy', label: copy.healthy },
|
||||
{ key: 'dormant', label: copy.dormant },
|
||||
];
|
||||
|
||||
const handleBellPress = () => {
|
||||
setActiveFilter('today');
|
||||
|
||||
if (dueTodayPlants.length === 0) {
|
||||
Alert.alert(copy.reminderTitle, copy.reminderNone);
|
||||
return;
|
||||
}
|
||||
|
||||
const previewNames = dueTodayPlants
|
||||
.slice(0, 6)
|
||||
.map((plant) => `- ${plant.name}`)
|
||||
.join('\n');
|
||||
const remainingCount = dueTodayPlants.length - 6;
|
||||
const remainingText = remainingCount > 0
|
||||
? `\n+ ${remainingCount} ${copy.more}`
|
||||
: '';
|
||||
|
||||
Alert.alert(
|
||||
copy.reminderTitle,
|
||||
`${copy.reminderDue.replace('{0}', dueTodayPlants.length.toString())}\n\n${previewNames}${remainingText}`
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoadingPlants) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.loadingContainer, { backgroundColor: colors.background }]} edges={['top', 'left', 'right']}>
|
||||
<ThemeBackdrop colors={colors} />
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={['top', 'left', 'right']}>
|
||||
<ThemeBackdrop colors={colors} />
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{ paddingBottom: Math.max(CONTENT_BOTTOM_PADDING, insets.bottom + CONTENT_BOTTOM_PADDING) },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<View style={[styles.avatarWrap, { backgroundColor: colors.primaryDark }]}>
|
||||
{profileImageUri ? (
|
||||
<Image source={{ uri: profileImageUri }} style={styles.avatarImage} />
|
||||
) : (
|
||||
<Ionicons name="leaf" size={20} color={colors.iconOnImage} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.headerTextBlock}>
|
||||
<Text style={[styles.greetingText, { color: colors.textSecondary }]}>{greetingText}</Text>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={[styles.nameText, { color: colors.text }]} numberOfLines={1}>
|
||||
{profileName || ''}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.creditsPill,
|
||||
{
|
||||
backgroundColor: colors.cardBg,
|
||||
borderColor: colors.cardBorder,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.creditsText, { color: colors.textSecondary }]} numberOfLines={1}>
|
||||
{creditsText}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.bellBtn,
|
||||
{
|
||||
backgroundColor: colors.cardBg,
|
||||
borderColor: colors.cardBorder,
|
||||
shadowColor: colors.cardShadow,
|
||||
},
|
||||
]}
|
||||
onPress={handleBellPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="notifications-outline" size={20} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={[styles.priorityCard, { backgroundColor: colors.primaryDark }]}>
|
||||
<View style={styles.priorityLabelRow}>
|
||||
<Ionicons name="water-outline" size={14} color={colors.heroButton} />
|
||||
<Text style={[styles.priorityLabel, { color: colors.heroButton }]}>{copy.needsWaterToday}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.priorityTitle, { color: colors.iconOnImage }]}>
|
||||
{copy.plantsThirsty.replace('{0}', thirstyCount.toString())}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.priorityButton,
|
||||
{
|
||||
backgroundColor: colors.heroButton,
|
||||
borderColor: colors.heroButtonBorder,
|
||||
},
|
||||
]}
|
||||
onPress={handleBellPress}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.priorityButtonText, { color: colors.text }]}>{copy.viewSchedule}</Text>
|
||||
<Ionicons name="arrow-forward" size={14} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Ionicons
|
||||
name="water"
|
||||
size={124}
|
||||
color={colors.overlay}
|
||||
style={styles.priorityBgIcon}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{plants.length === 0 && (
|
||||
<OnboardingChecklist plantsCount={plants.length} colors={colors} router={router} t={t} />
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filterRow}
|
||||
>
|
||||
{chips.map(chip => (
|
||||
<TouchableOpacity
|
||||
key={chip.key}
|
||||
style={[
|
||||
activeFilter === chip.key ? styles.filterChipActive : styles.filterChip,
|
||||
activeFilter === chip.key
|
||||
? { backgroundColor: colors.primary, shadowColor: colors.fabShadow }
|
||||
: { backgroundColor: colors.cardBg, borderColor: colors.cardBorder },
|
||||
]}
|
||||
onPress={() => setActiveFilter(chip.key)}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
activeFilter === chip.key ? styles.filterTextActive : styles.filterText,
|
||||
{ color: activeFilter === chip.key ? colors.onPrimary : colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{chip.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.collectionHeader}>
|
||||
<Text style={[styles.collectionTitle, { color: colors.text }]}>{copy.collectionTitle}</Text>
|
||||
<View style={[styles.collectionCountPill, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
|
||||
<Text style={[styles.collectionCountText, { color: colors.textSecondary }]}>
|
||||
{copy.collectionCount.replace('{0}', filteredPlants.length.toString())}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{filteredPlants.length === 0 ? (
|
||||
<View style={[styles.emptyState, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
|
||||
<View style={[styles.emptyIconWrap, { backgroundColor: colors.surfaceMuted }]}>
|
||||
<Ionicons name="leaf-outline" size={28} color={colors.textMuted} />
|
||||
</View>
|
||||
<Text style={[styles.emptyTitle, { color: colors.text }]}>
|
||||
{plants.length === 0 ? copy.emptyCollectionTitle : copy.noneInFilter}
|
||||
</Text>
|
||||
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
|
||||
{plants.length === 0 ? copy.emptyCollectionHint : copy.noneInFilter}
|
||||
</Text>
|
||||
{plants.length === 0 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.emptyCta, { backgroundColor: colors.primary, shadowColor: colors.fabShadow }]}
|
||||
onPress={() => router.push('/scanner')}
|
||||
activeOpacity={0.86}
|
||||
>
|
||||
<Ionicons name="scan-outline" size={15} color={colors.onPrimary} />
|
||||
<Text style={[styles.emptyCtaText, { color: colors.onPrimary }]}>{copy.scanFirstPlant}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
filteredPlants.map((plant) => {
|
||||
const daysUntil = getDaysUntilWatering(plant);
|
||||
const thirsty = daysUntil === 0;
|
||||
const nextWaterText = thirsty
|
||||
? copy.today
|
||||
: t.inXDays.replace('{0}', daysUntil.toString());
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={plant.id}
|
||||
style={[
|
||||
styles.plantCard,
|
||||
{
|
||||
backgroundColor: colors.cardBg,
|
||||
borderColor: colors.cardBorder,
|
||||
shadowColor: colors.cardShadow,
|
||||
},
|
||||
]}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => router.push(`/plant/${plant.id}`)}
|
||||
>
|
||||
<View style={[styles.plantImageWrap, { borderColor: colors.border }]}>
|
||||
<SafeImage uri={plant.imageUri} style={[styles.plantImage, { backgroundColor: colors.surfaceStrong }]} />
|
||||
</View>
|
||||
|
||||
<View style={styles.plantBody}>
|
||||
<View style={styles.plantHeadRow}>
|
||||
<View style={styles.plantTitleCol}>
|
||||
<Text style={[styles.plantName, { color: colors.text }]} numberOfLines={1}>
|
||||
{plant.name}
|
||||
</Text>
|
||||
<Text style={[styles.botanicalName, { color: colors.textSecondary }]} numberOfLines={1}>
|
||||
{plant.botanicalName}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
|
||||
</View>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<View
|
||||
style={[
|
||||
thirsty ? styles.statusThirsty : styles.statusHealthy,
|
||||
{ backgroundColor: thirsty ? colors.dangerSoft : colors.successSoft },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
thirsty ? styles.statusThirstyText : styles.statusHealthyText,
|
||||
{ color: thirsty ? colors.danger : colors.success },
|
||||
]}
|
||||
>
|
||||
{thirsty ? copy.thirsty : copy.healthyStatus}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.nextWaterPill, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}>
|
||||
<Ionicons name="water-outline" size={12} color={colors.textMuted} />
|
||||
<Text style={[styles.waterMeta, { color: colors.textMuted }]}>
|
||||
{copy.nextWaterLabel}: {nextWaterText}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<TouchableOpacity
|
||||
ref={fabRef}
|
||||
style={[
|
||||
styles.fab,
|
||||
{ bottom: Math.max(FAB_BOTTOM_OFFSET, insets.bottom + FAB_BOTTOM_OFFSET) },
|
||||
{
|
||||
backgroundColor: colors.fabBg,
|
||||
borderColor: colors.surface,
|
||||
shadowColor: colors.fabShadow,
|
||||
},
|
||||
]}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => router.push('/scanner')}
|
||||
onLayout={() => {
|
||||
fabRef.current?.measureInWindow((x, y, width, height) => {
|
||||
registerLayout('fab', { x, y, width, height });
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add" size={30} color={colors.onPrimary} />
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 14,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 26,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flex: 1,
|
||||
},
|
||||
headerTextBlock: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
avatarWrap: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
avatarImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
greetingText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
nameRow: {
|
||||
marginTop: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
},
|
||||
nameText: {
|
||||
fontSize: 21,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
creditsPill: {
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
creditsText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.3,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
bellBtn: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
elevation: 2,
|
||||
},
|
||||
priorityCard: {
|
||||
borderRadius: 32,
|
||||
padding: 24,
|
||||
marginBottom: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
priorityLabelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginBottom: 8,
|
||||
},
|
||||
priorityLabel: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.9,
|
||||
},
|
||||
priorityTitle: {
|
||||
fontSize: 29,
|
||||
fontWeight: '700',
|
||||
lineHeight: 36,
|
||||
maxWidth: 260,
|
||||
marginBottom: 14,
|
||||
},
|
||||
priorityButton: {
|
||||
alignSelf: 'flex-start',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
priorityButtonText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
priorityBgIcon: {
|
||||
position: 'absolute',
|
||||
right: -18,
|
||||
bottom: -22,
|
||||
},
|
||||
filterRow: {
|
||||
gap: 10,
|
||||
paddingVertical: 2,
|
||||
paddingRight: 4,
|
||||
marginBottom: 16,
|
||||
},
|
||||
collectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
collectionTitle: {
|
||||
fontSize: 19,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
collectionCountPill: {
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
collectionCountText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
filterChip: {
|
||||
paddingHorizontal: 22,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
},
|
||||
filterChipActive: {
|
||||
paddingHorizontal: 22,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 16,
|
||||
shadowOpacity: 0.24,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
elevation: 3,
|
||||
},
|
||||
filterText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
filterTextActive: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
plantCard: {
|
||||
borderRadius: 30,
|
||||
borderWidth: 1,
|
||||
padding: 15,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 14,
|
||||
marginBottom: 14,
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 3,
|
||||
},
|
||||
plantImageWrap: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
plantImage: {
|
||||
width: 98,
|
||||
height: 98,
|
||||
borderRadius: 20,
|
||||
},
|
||||
plantBody: {
|
||||
flex: 1,
|
||||
paddingTop: 2,
|
||||
},
|
||||
plantHeadRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
gap: 8,
|
||||
},
|
||||
plantTitleCol: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
plantName: {
|
||||
fontWeight: '700',
|
||||
fontSize: 19,
|
||||
letterSpacing: 0.15,
|
||||
},
|
||||
botanicalName: {
|
||||
fontSize: 12.5,
|
||||
fontStyle: 'italic',
|
||||
fontWeight: '500',
|
||||
marginTop: 2,
|
||||
opacity: 0.95,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
statusThirsty: {
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
statusHealthy: {
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
statusThirstyText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
statusHealthyText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
waterMeta: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
nextWaterPill: {
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 9,
|
||||
paddingVertical: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
emptyState: {
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
paddingVertical: 32,
|
||||
paddingHorizontal: 22,
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
emptyIconWrap: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyCta: {
|
||||
marginTop: 8,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 4,
|
||||
},
|
||||
emptyCtaText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: 24,
|
||||
width: 66,
|
||||
height: 66,
|
||||
borderRadius: 33,
|
||||
borderWidth: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowOpacity: 0.38,
|
||||
shadowRadius: 14,
|
||||
shadowOffset: { width: 0, height: 5 },
|
||||
elevation: 9,
|
||||
},
|
||||
checklistCard: {
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
checklistTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 16,
|
||||
},
|
||||
checklistGrid: {
|
||||
gap: 12,
|
||||
},
|
||||
checklistItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
checkIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
checklistText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
526
app/(tabs)/profile.tsx
Normal file
526
app/(tabs)/profile.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Image,
|
||||
Alert,
|
||||
TextInput,
|
||||
Keyboard,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { useColors } from '../../constants/Colors';
|
||||
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
||||
import { Language } from '../../types';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const getDaysUntilWatering = (lastWatered: string, intervalDays: number): number => {
|
||||
const lastWateredTs = new Date(lastWatered).getTime();
|
||||
if (Number.isNaN(lastWateredTs)) return 0;
|
||||
const dueTs = lastWateredTs + (intervalDays * DAY_MS);
|
||||
const remainingMs = dueTs - Date.now();
|
||||
if (remainingMs <= 0) return 0;
|
||||
return Math.ceil(remainingMs / DAY_MS);
|
||||
};
|
||||
|
||||
const getProfileCopy = (language: Language) => {
|
||||
if (language === 'de') {
|
||||
return {
|
||||
overviewLabel: 'Überblick',
|
||||
statPlants: 'Pflanzen',
|
||||
statDueToday: 'Heute fällig',
|
||||
statReminders: 'Erinnerungen an',
|
||||
account: 'Account',
|
||||
changePhoto: 'Foto ändern',
|
||||
removePhoto: 'Foto entfernen',
|
||||
nameLabel: 'Name',
|
||||
namePlaceholder: 'Dein Name',
|
||||
saveName: 'Name speichern',
|
||||
photoErrorTitle: 'Fehler',
|
||||
photoErrorMessage: 'Profilfoto konnte nicht geladen werden.',
|
||||
nameErrorTitle: 'Name fehlt',
|
||||
nameErrorMessage: 'Bitte gib einen Namen ein.',
|
||||
menuSettings: 'Einstellungen',
|
||||
menuBilling: 'Abo & Credits',
|
||||
menuData: 'Daten & Datenschutz',
|
||||
logout: 'Abmelden',
|
||||
logoutConfirmTitle: 'Abmelden?',
|
||||
logoutConfirmMessage: 'Möchtest du dich wirklich abmelden?',
|
||||
logoutConfirmBtn: 'Abmelden',
|
||||
};
|
||||
}
|
||||
if (language === 'es') {
|
||||
return {
|
||||
overviewLabel: 'Resumen',
|
||||
statPlants: 'Plantas',
|
||||
statDueToday: 'Vencen hoy',
|
||||
statReminders: 'Recordatorios',
|
||||
account: 'Cuenta',
|
||||
changePhoto: 'Cambiar foto',
|
||||
removePhoto: 'Eliminar foto',
|
||||
nameLabel: 'Nombre',
|
||||
namePlaceholder: 'Tu nombre',
|
||||
saveName: 'Guardar nombre',
|
||||
photoErrorTitle: 'Error',
|
||||
photoErrorMessage: 'No se pudo cargar la foto.',
|
||||
nameErrorTitle: 'Falta nombre',
|
||||
nameErrorMessage: 'Por favor ingresa un nombre.',
|
||||
menuSettings: 'Ajustes',
|
||||
menuBilling: 'Suscripción y Créditos',
|
||||
menuData: 'Datos y Privacidad',
|
||||
logout: 'Cerrar sesión',
|
||||
logoutConfirmTitle: '¿Cerrar sesión?',
|
||||
logoutConfirmMessage: '¿Realmente quieres cerrar sesión?',
|
||||
logoutConfirmBtn: 'Cerrar sesión',
|
||||
};
|
||||
}
|
||||
return {
|
||||
overviewLabel: 'Overview',
|
||||
statPlants: 'Plants',
|
||||
statDueToday: 'Due today',
|
||||
statReminders: 'Reminders on',
|
||||
account: 'Account',
|
||||
changePhoto: 'Change photo',
|
||||
removePhoto: 'Remove photo',
|
||||
nameLabel: 'Name',
|
||||
namePlaceholder: 'Your name',
|
||||
saveName: 'Save name',
|
||||
photoErrorTitle: 'Error',
|
||||
photoErrorMessage: 'Could not load profile photo.',
|
||||
nameErrorTitle: 'Name missing',
|
||||
nameErrorMessage: 'Please enter a name.',
|
||||
menuSettings: 'Preferences',
|
||||
menuBilling: 'Billing & Credits',
|
||||
menuData: 'Data & Privacy',
|
||||
logout: 'Sign Out',
|
||||
logoutConfirmTitle: 'Sign out?',
|
||||
logoutConfirmMessage: 'Do you really want to sign out?',
|
||||
logoutConfirmBtn: 'Sign Out',
|
||||
};
|
||||
};
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const {
|
||||
plants,
|
||||
language,
|
||||
t,
|
||||
isDarkMode,
|
||||
colorPalette,
|
||||
profileImageUri,
|
||||
setProfileImage,
|
||||
profileName,
|
||||
setProfileName,
|
||||
signOut,
|
||||
} = useApp();
|
||||
|
||||
const router = useRouter();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
|
||||
const [isUpdatingImage, setIsUpdatingImage] = useState(false);
|
||||
const [isSavingName, setIsSavingName] = useState(false);
|
||||
const [draftName, setDraftName] = useState(profileName);
|
||||
|
||||
const copy = useMemo(() => getProfileCopy(language), [language]);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftName(profileName);
|
||||
}, [profileName]);
|
||||
|
||||
const normalizedDraftName = draftName.trim();
|
||||
const canSaveName = normalizedDraftName.length > 0 && normalizedDraftName !== profileName;
|
||||
|
||||
const dueTodayCount = useMemo(
|
||||
() => plants.filter(plant => getDaysUntilWatering(plant.lastWatered, plant.careInfo.waterIntervalDays) === 0).length,
|
||||
[plants]
|
||||
);
|
||||
|
||||
const remindersEnabledCount = useMemo(
|
||||
() => plants.filter(plant => Boolean(plant.notificationsEnabled)).length,
|
||||
[plants]
|
||||
);
|
||||
|
||||
const handlePickProfileImage = async () => {
|
||||
if (isUpdatingImage) return;
|
||||
|
||||
setIsUpdatingImage(true);
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
quality: 0.85,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const asset = result.assets[0];
|
||||
const imageUri = asset.base64
|
||||
? `data:image/jpeg;base64,${asset.base64}`
|
||||
: asset.uri;
|
||||
await setProfileImage(imageUri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick profile image', error);
|
||||
Alert.alert(copy.photoErrorTitle, copy.photoErrorMessage);
|
||||
} finally {
|
||||
setIsUpdatingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePhoto = async () => {
|
||||
await setProfileImage(null);
|
||||
};
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!normalizedDraftName) {
|
||||
Alert.alert(copy.nameErrorTitle, copy.nameErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canSaveName) {
|
||||
Keyboard.dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingName(true);
|
||||
try {
|
||||
await setProfileName(normalizedDraftName);
|
||||
Keyboard.dismiss();
|
||||
} finally {
|
||||
setIsSavingName(false);
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ label: copy.menuSettings, icon: 'settings-outline', route: '/profile/preferences' as any },
|
||||
{ label: copy.menuBilling, icon: 'card-outline', route: '/profile/billing' as any },
|
||||
{ label: copy.menuData, icon: 'shield-checkmark-outline', route: '/profile/data' as any },
|
||||
];
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={['top', 'left', 'right']} style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<ThemeBackdrop colors={colors} />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{t.tabProfile}</Text>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{copy.overviewLabel}</Text>
|
||||
<View style={styles.statsRow}>
|
||||
{[
|
||||
{ label: copy.statPlants, value: plants.length.toString() },
|
||||
{ label: copy.statDueToday, value: dueTodayCount.toString() },
|
||||
{ label: copy.statReminders, value: remindersEnabledCount.toString() },
|
||||
].map((item) => (
|
||||
<View
|
||||
key={item.label}
|
||||
style={[styles.statCard, { backgroundColor: colors.surfaceStrong, borderColor: colors.borderStrong }]}
|
||||
>
|
||||
<Text style={[styles.statValue, { color: colors.text }]}>{item.value}</Text>
|
||||
<Text style={[styles.statLabel, { color: colors.textSecondary }]}>{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, styles.accountCard, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{copy.account}</Text>
|
||||
|
||||
<View style={styles.accountRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.avatarFrame, { backgroundColor: colors.primaryDark }]}
|
||||
onPress={handlePickProfileImage}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{profileImageUri ? (
|
||||
<Image source={{ uri: profileImageUri }} style={styles.avatarImage} />
|
||||
) : (
|
||||
<Ionicons name="person" size={34} color={colors.iconOnImage} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.accountMeta}>
|
||||
<Text style={[styles.currentName, { color: colors.text }]}>{profileName}</Text>
|
||||
<Text style={[styles.plantsCount, { color: colors.textSecondary }]}>
|
||||
{plants.length} {copy.statPlants}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.photoButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.photoActionBtn, { backgroundColor: colors.primary }]}
|
||||
onPress={handlePickProfileImage}
|
||||
activeOpacity={0.85}
|
||||
disabled={isUpdatingImage}
|
||||
>
|
||||
<Text style={[styles.photoActionText, { color: colors.onPrimary }]}>
|
||||
{isUpdatingImage ? '...' : copy.changePhoto}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{profileImageUri ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.photoActionBtn, styles.photoSecondaryBtn, { borderColor: colors.borderStrong }]}
|
||||
onPress={handleRemovePhoto}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.photoSecondaryText, { color: colors.textSecondary }]}>
|
||||
{copy.removePhoto}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text style={[styles.fieldLabel, { color: colors.textSecondary }]}>{copy.nameLabel}</Text>
|
||||
<View style={styles.nameRow}>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.nameInput,
|
||||
{
|
||||
color: colors.text,
|
||||
backgroundColor: colors.inputBg,
|
||||
borderColor: colors.inputBorder,
|
||||
},
|
||||
]}
|
||||
value={draftName}
|
||||
placeholder={copy.namePlaceholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
onChangeText={setDraftName}
|
||||
maxLength={40}
|
||||
autoCapitalize="words"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSaveName}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.saveNameBtn,
|
||||
{
|
||||
backgroundColor: canSaveName ? colors.primary : colors.surfaceStrong,
|
||||
borderColor: canSaveName ? colors.primaryDark : colors.borderStrong,
|
||||
},
|
||||
]}
|
||||
onPress={handleSaveName}
|
||||
disabled={!canSaveName || isSavingName}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.saveNameText, { color: canSaveName ? colors.onPrimary : colors.textMuted }]}>
|
||||
{isSavingName ? '...' : copy.saveName}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder, padding: 0, overflow: 'hidden' }]}>
|
||||
{menuItems.map((item, idx) => (
|
||||
<TouchableOpacity
|
||||
key={item.route}
|
||||
style={[
|
||||
styles.menuItem,
|
||||
idx !== menuItems.length - 1 && { borderBottomWidth: 1, borderBottomColor: colors.borderStrong }
|
||||
]}
|
||||
onPress={() => router.push(item.route)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.menuItemLeft}>
|
||||
<Ionicons name={item.icon as any} size={20} color={colors.text} />
|
||||
<Text style={[styles.menuItemText, { color: colors.text }]}>{item.label}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
{/* Logout */}
|
||||
<TouchableOpacity
|
||||
style={[styles.logoutBtn, { borderColor: colors.dangerSoft, backgroundColor: colors.dangerSoft }]}
|
||||
activeOpacity={0.78}
|
||||
onPress={() => {
|
||||
Alert.alert(copy.logoutConfirmTitle, copy.logoutConfirmMessage, [
|
||||
{ text: t.cancel, style: 'cancel' },
|
||||
{
|
||||
text: copy.logoutConfirmBtn,
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await signOut();
|
||||
router.replace('/auth/login');
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="log-out-outline" size={18} color={colors.danger} />
|
||||
<Text style={[styles.logoutText, { color: colors.danger }]}>{copy.logout}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ height: 40 }} />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
title: {
|
||||
marginTop: 14,
|
||||
marginBottom: 14,
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
},
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
marginBottom: 12,
|
||||
gap: 10,
|
||||
},
|
||||
accountCard: {
|
||||
marginBottom: 14,
|
||||
},
|
||||
logoutBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
paddingVertical: 14,
|
||||
marginBottom: 12,
|
||||
},
|
||||
logoutText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 22,
|
||||
lineHeight: 24,
|
||||
fontWeight: '700',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
accountRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
accountMeta: {
|
||||
flex: 1,
|
||||
gap: 3,
|
||||
},
|
||||
currentName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
plantsCount: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
avatarFrame: {
|
||||
width: 74,
|
||||
height: 74,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
avatarImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
photoButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
photoActionBtn: {
|
||||
borderRadius: 10,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
photoSecondaryBtn: {
|
||||
borderWidth: 1,
|
||||
},
|
||||
photoActionText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
photoSecondaryText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
fieldLabel: {
|
||||
marginTop: 2,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
nameRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
nameInput: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
saveNameBtn: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
saveNameText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
menuItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
}
|
||||
});
|
||||
613
app/(tabs)/search.tsx
Normal file
613
app/(tabs)/search.tsx
Normal file
@@ -0,0 +1,613 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
FlatList,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { useColors } from '../../constants/Colors';
|
||||
import { Plant } from '../../types';
|
||||
import { PlantCard } from '../../components/PlantCard';
|
||||
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
||||
import {
|
||||
DatabaseEntry,
|
||||
PlantDatabaseService,
|
||||
SemanticSearchStatus,
|
||||
} from '../../services/plantDatabaseService';
|
||||
import { normalizeSearchText, rankHybridEntries } from '../../utils/hybridSearch';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const CARD_GAP = 12;
|
||||
const CARD_WIDTH = (width - 40 - CARD_GAP) / 2;
|
||||
const SEARCH_DEBOUNCE_MS = 250;
|
||||
const SEMANTIC_SEARCH_CREDIT_COST = 2;
|
||||
|
||||
const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
||||
if (language === 'de') {
|
||||
return {
|
||||
creditsLabel: 'Credits',
|
||||
deepSearchCost: `Deep Search kostet ${SEMANTIC_SEARCH_CREDIT_COST} Credits`,
|
||||
insufficientCredits: 'Nicht genug Credits fuer AI Deep Search.',
|
||||
managePlan: 'Plan verwalten',
|
||||
};
|
||||
}
|
||||
|
||||
if (language === 'es') {
|
||||
return {
|
||||
creditsLabel: 'Creditos',
|
||||
deepSearchCost: `Deep Search cuesta ${SEMANTIC_SEARCH_CREDIT_COST} creditos`,
|
||||
insufficientCredits: 'No tienes creditos suficientes para AI Deep Search.',
|
||||
managePlan: 'Gestionar plan',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
creditsLabel: 'Credits',
|
||||
deepSearchCost: `Deep Search costs ${SEMANTIC_SEARCH_CREDIT_COST} credits`,
|
||||
insufficientCredits: 'Not enough credits for AI Deep Search.',
|
||||
managePlan: 'Manage plan',
|
||||
};
|
||||
};
|
||||
|
||||
const parseColor = (value: string) => {
|
||||
if (value.startsWith('#')) {
|
||||
const cleaned = value.replace('#', '');
|
||||
const normalized = cleaned.length === 3
|
||||
? cleaned.split('').map((c) => `${c}${c}`).join('')
|
||||
: cleaned;
|
||||
const int = Number.parseInt(normalized, 16);
|
||||
return {
|
||||
r: (int >> 16) & 255,
|
||||
g: (int >> 8) & 255,
|
||||
b: int & 255,
|
||||
};
|
||||
}
|
||||
|
||||
const match = value.match(/rgba?\(([^)]+)\)/i);
|
||||
if (!match) return { r: 255, g: 255, b: 255 };
|
||||
const parts = match[1].split(',').map((part) => part.trim());
|
||||
return {
|
||||
r: Number.parseFloat(parts[0]) || 255,
|
||||
g: Number.parseFloat(parts[1]) || 255,
|
||||
b: Number.parseFloat(parts[2]) || 255,
|
||||
};
|
||||
};
|
||||
|
||||
const blendColors = (baseColor: string, tintColor: string, tintWeight: number) => {
|
||||
const base = parseColor(baseColor);
|
||||
const tint = parseColor(tintColor);
|
||||
const weight = Math.max(0, Math.min(1, tintWeight));
|
||||
const r = Math.round(base.r + (tint.r - base.r) * weight);
|
||||
const g = Math.round(base.g + (tint.g - base.g) * weight);
|
||||
const b = Math.round(base.b + (tint.b - base.b) * weight);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
};
|
||||
|
||||
const relativeLuminance = (value: string) => {
|
||||
const { r, g, b } = parseColor(value);
|
||||
const [nr, ng, nb] = [r, g, b].map((channel) => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.03928
|
||||
? normalized / 12.92
|
||||
: ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * nr + 0.7152 * ng + 0.0722 * nb;
|
||||
};
|
||||
|
||||
const contrastRatio = (a: string, b: string) => {
|
||||
const l1 = relativeLuminance(a);
|
||||
const l2 = relativeLuminance(b);
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
};
|
||||
|
||||
const pickBestTextColor = (bgColor: string, candidates: string[]) => {
|
||||
let best = candidates[0];
|
||||
let bestRatio = contrastRatio(bgColor, best);
|
||||
for (let i = 1; i < candidates.length; i += 1) {
|
||||
const ratio = contrastRatio(bgColor, candidates[i]);
|
||||
if (ratio > bestRatio) {
|
||||
best = candidates[i];
|
||||
bestRatio = ratio;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const chunkIntoRows = <T,>(items: T[], size = 2): T[][] => {
|
||||
const rows: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
rows.push(items.slice(i, i + size));
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
export default function SearchScreen() {
|
||||
const {
|
||||
plants,
|
||||
isDarkMode,
|
||||
colorPalette,
|
||||
t,
|
||||
language,
|
||||
billingSummary,
|
||||
refreshBillingSummary,
|
||||
} = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
const router = useRouter();
|
||||
const billingCopy = getBillingCopy(language);
|
||||
const availableCredits = billingSummary?.credits.available ?? 0;
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [isDeepSearching, setIsDeepSearching] = useState(false);
|
||||
const [aiStatus, setAiStatus] = useState<SemanticSearchStatus | 'idle' | 'loading'>('idle');
|
||||
const [aiResults, setAiResults] = useState<DatabaseEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedQuery(searchQuery.trim());
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setAiStatus('idle');
|
||||
setAiResults([]);
|
||||
}, [debouncedQuery, language]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshBillingSummary();
|
||||
}, [refreshBillingSummary]);
|
||||
|
||||
const getCategoryBackground = (baseTint: string, accent: string) => {
|
||||
return isDarkMode ? baseTint : blendColors(baseTint, accent, 0.2);
|
||||
};
|
||||
|
||||
const getCategoryTextColor = (bgColor: string, accent: string) => {
|
||||
const tintedDark = blendColors(accent, '#000000', 0.58);
|
||||
const tintedLight = blendColors(accent, '#ffffff', 0.64);
|
||||
return pickBestTextColor(bgColor, [
|
||||
isDarkMode ? tintedLight : tintedDark,
|
||||
colors.text,
|
||||
colors.textOnImage,
|
||||
]);
|
||||
};
|
||||
|
||||
const categories = [
|
||||
{ id: 'easy', name: t.catCareEasy, bg: getCategoryBackground(colors.successTint, colors.success), accent: colors.success },
|
||||
{ id: 'low_light', name: t.catLowLight, bg: getCategoryBackground(colors.infoTint, colors.info), accent: colors.info },
|
||||
{ id: 'bright_light', name: t.catBrightLight, bg: getCategoryBackground(colors.primaryTint, colors.primaryDark), accent: colors.primaryDark },
|
||||
{ id: 'sun', name: t.catSun, bg: getCategoryBackground(colors.warningTint, colors.warning), accent: colors.warning },
|
||||
{ id: 'pet_friendly', name: t.catPetFriendly, bg: getCategoryBackground(colors.dangerTint, colors.danger), accent: colors.danger },
|
||||
{ id: 'air_purifier', name: t.catAirPurifier, bg: getCategoryBackground(colors.primaryTint, colors.primary), accent: colors.primary },
|
||||
{ id: 'high_humidity', name: t.catHighHumidity, bg: getCategoryBackground(colors.infoTint, colors.info), accent: colors.info },
|
||||
{ id: 'hanging', name: t.catHanging, bg: getCategoryBackground(colors.successTint, colors.success), accent: colors.success },
|
||||
{ id: 'patterned', name: t.catPatterned, bg: getCategoryBackground(colors.dangerTint, colors.primaryDark), accent: colors.primaryDark },
|
||||
{ id: 'flowering', name: t.catFlowering, bg: getCategoryBackground(colors.primaryTint, colors.primaryDark), accent: colors.primaryDark },
|
||||
{ id: 'succulent', name: t.catSucculents, bg: getCategoryBackground(colors.warningTint, colors.warning), accent: colors.warning },
|
||||
{ id: 'tree', name: t.catTree, bg: getCategoryBackground(colors.surfaceStrong, colors.textSecondary), accent: colors.textSecondary },
|
||||
{ id: 'large', name: t.catLarge, bg: getCategoryBackground(colors.surface, colors.textMuted), accent: colors.textMuted },
|
||||
{ id: 'medicinal', name: t.catMedicinal, bg: getCategoryBackground(colors.successTint, colors.success), accent: colors.success },
|
||||
];
|
||||
|
||||
const normalizedQuery = normalizeSearchText(debouncedQuery);
|
||||
const isResultMode = Boolean(normalizedQuery);
|
||||
|
||||
const localResults = useMemo(() => {
|
||||
if (!normalizedQuery) {
|
||||
return [] as Plant[];
|
||||
}
|
||||
|
||||
return rankHybridEntries(plants, normalizedQuery, 30)
|
||||
.map((entry) => entry.entry);
|
||||
}, [plants, normalizedQuery]);
|
||||
|
||||
const [lexiconResults, setLexiconResults] = useState<DatabaseEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedQuery) {
|
||||
setLexiconResults([]);
|
||||
return;
|
||||
}
|
||||
let isCancelled = false;
|
||||
PlantDatabaseService.searchPlants(normalizedQuery, language, {
|
||||
limit: 30,
|
||||
}).then((results) => {
|
||||
if (!isCancelled) setLexiconResults(results);
|
||||
}).catch(console.error);
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [normalizedQuery, language]);
|
||||
|
||||
const filteredAiResults = aiResults;
|
||||
|
||||
const showAiSection = aiStatus !== 'idle' || filteredAiResults.length > 0;
|
||||
const canRunDeepSearch = (
|
||||
searchQuery.trim().length >= 3 &&
|
||||
!isDeepSearching &&
|
||||
availableCredits >= SEMANTIC_SEARCH_CREDIT_COST
|
||||
);
|
||||
|
||||
const handleDeepSearch = async () => {
|
||||
const query = searchQuery.trim();
|
||||
if (query.length < 3) return;
|
||||
if (availableCredits < SEMANTIC_SEARCH_CREDIT_COST) {
|
||||
setAiStatus('insufficient_credits');
|
||||
setAiResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeepSearching(true);
|
||||
setAiStatus('loading');
|
||||
setAiResults([]);
|
||||
|
||||
try {
|
||||
const response = await PlantDatabaseService.semanticSearchDetailed(query, language);
|
||||
setAiStatus(response.status);
|
||||
setAiResults(response.results);
|
||||
} catch (error) {
|
||||
console.error('Deep search failed', error);
|
||||
setAiStatus('provider_error');
|
||||
setAiResults([]);
|
||||
} finally {
|
||||
setIsDeepSearching(false);
|
||||
await refreshBillingSummary();
|
||||
}
|
||||
};
|
||||
|
||||
const openCategoryLexicon = (categoryId: string, categoryName: string) => {
|
||||
router.push({
|
||||
pathname: '/lexicon',
|
||||
params: {
|
||||
categoryId,
|
||||
categoryLabel: encodeURIComponent(categoryName),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
setSearchQuery('');
|
||||
setDebouncedQuery('');
|
||||
setAiStatus('idle');
|
||||
setAiResults([]);
|
||||
};
|
||||
|
||||
const openLexiconDetail = (entry: DatabaseEntry) => {
|
||||
router.push({
|
||||
pathname: '/lexicon',
|
||||
params: { detail: encodeURIComponent(JSON.stringify(entry)) },
|
||||
});
|
||||
};
|
||||
|
||||
const renderGrid = (
|
||||
items: Array<Plant | DatabaseEntry>,
|
||||
type: 'local' | 'lexicon' | 'ai',
|
||||
) => {
|
||||
const rows = chunkIntoRows(items, 2);
|
||||
|
||||
return (
|
||||
<View style={styles.grid}>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<View key={`${type}-row-${rowIndex}`} style={styles.gridRow}>
|
||||
{row.map((item, itemIndex) => (
|
||||
<View key={`${type}-${item.name}-${itemIndex}`} style={styles.cardWrapper}>
|
||||
<PlantCard
|
||||
plant={item}
|
||||
width={CARD_WIDTH}
|
||||
onPress={() => {
|
||||
if (type === 'local' && 'id' in item) {
|
||||
router.push(`/plant/${item.id}`);
|
||||
return;
|
||||
}
|
||||
openLexiconDetail(item as DatabaseEntry);
|
||||
}}
|
||||
t={t}
|
||||
isDark={isDarkMode}
|
||||
colorPalette={colorPalette}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{row.length === 1 ? <View style={styles.cardSpacer} /> : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const aiStatusText = (() => {
|
||||
if (aiStatus === 'loading') return t.searchAiLoading;
|
||||
if (aiStatus === 'timeout') return t.searchAiUnavailable;
|
||||
if (aiStatus === 'provider_error') return t.searchAiUnavailable;
|
||||
if (aiStatus === 'insufficient_credits') return billingCopy.insufficientCredits;
|
||||
if (aiStatus === 'no_results') return t.searchAiNoResults;
|
||||
return null;
|
||||
})();
|
||||
|
||||
const SectionTitle = ({ label, count }: { label: string; count: number }) => (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{label}</Text>
|
||||
<Text style={[styles.sectionCount, { color: colors.textSecondary }]}>{count}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<ThemeBackdrop colors={colors} />
|
||||
|
||||
<Text style={[styles.title, { color: colors.text }]}>{t.searchTitle}</Text>
|
||||
|
||||
<View style={[styles.searchBar, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
|
||||
<Ionicons name="search" size={20} color={colors.textMuted} />
|
||||
<TextInput
|
||||
style={[styles.searchInput, { color: colors.text }]}
|
||||
placeholder={t.searchPlaceholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
returnKeyType="search"
|
||||
/>
|
||||
{searchQuery ? (
|
||||
<TouchableOpacity onPress={clearAll} hitSlop={8}>
|
||||
<Ionicons name="close" size={20} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
horizontal
|
||||
data={categories}
|
||||
keyExtractor={item => item.id}
|
||||
style={styles.chipsList}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.chipsContent}
|
||||
renderItem={({ item }) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.catChip,
|
||||
{
|
||||
backgroundColor: item.bg,
|
||||
borderColor: colors.chipBorder,
|
||||
},
|
||||
]}
|
||||
onPress={() => openCategoryLexicon(item.id, item.name)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.catChipText, { color: getCategoryTextColor(item.bg, item.accent) }]}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{searchQuery.trim().length >= 3 ? (
|
||||
<View style={styles.deepSearchWrap}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.deepSearchBtn,
|
||||
{
|
||||
backgroundColor: canRunDeepSearch ? colors.primary : colors.surfaceStrong,
|
||||
borderColor: canRunDeepSearch ? colors.primaryDark : colors.borderStrong,
|
||||
shadowColor: canRunDeepSearch ? colors.fabShadow : colors.cardShadow,
|
||||
},
|
||||
]}
|
||||
onPress={handleDeepSearch}
|
||||
disabled={!canRunDeepSearch}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Ionicons
|
||||
name="sparkles"
|
||||
size={16}
|
||||
color={canRunDeepSearch ? colors.onPrimary : colors.textMuted}
|
||||
/>
|
||||
<Text style={[styles.deepSearchText, { color: canRunDeepSearch ? colors.onPrimary : colors.textMuted }]}>
|
||||
{isDeepSearching ? t.searchAiLoading : t.searchDeepAction}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.creditMetaRow}>
|
||||
<Text style={[styles.creditMetaText, { color: colors.textSecondary }]}>
|
||||
{billingCopy.creditsLabel}: {availableCredits}
|
||||
</Text>
|
||||
<Text style={[styles.creditMetaText, { color: colors.textMuted }]}>
|
||||
{billingCopy.deepSearchCost}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{isResultMode ? (
|
||||
<ScrollView style={styles.results} contentContainerStyle={styles.resultsContent} showsVerticalScrollIndicator={false}>
|
||||
<SectionTitle label={t.searchMyPlants} count={localResults.length} />
|
||||
{localResults.length > 0 ? (
|
||||
renderGrid(localResults, 'local')
|
||||
) : (
|
||||
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>{t.searchNoLocalResults}</Text>
|
||||
)}
|
||||
|
||||
<SectionTitle label={t.searchLexicon} count={lexiconResults.length} />
|
||||
{lexiconResults.length > 0 ? (
|
||||
renderGrid(lexiconResults, 'lexicon')
|
||||
) : (
|
||||
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>{t.searchNoLexiconResults}</Text>
|
||||
)}
|
||||
|
||||
{showAiSection ? (
|
||||
<View style={styles.aiSection}>
|
||||
<SectionTitle label={t.searchAiSection} count={filteredAiResults.length} />
|
||||
{aiStatus === 'loading' ? (
|
||||
<View style={styles.aiLoadingRow}>
|
||||
<Ionicons name="sparkles" size={14} color={colors.primary} />
|
||||
<Text style={[styles.aiStatusText, { color: colors.textSecondary }]}>{aiStatusText}</Text>
|
||||
</View>
|
||||
) : filteredAiResults.length > 0 ? (
|
||||
renderGrid(filteredAiResults, 'ai')
|
||||
) : aiStatusText ? (
|
||||
<View style={styles.aiStatusBlock}>
|
||||
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>{aiStatusText}</Text>
|
||||
{aiStatus === 'insufficient_credits' ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.managePlanBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surfaceStrong }]}
|
||||
onPress={() => router.push('/(tabs)/profile')}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.managePlanText, { color: colors.text }]}>{billingCopy.managePlan}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.results}
|
||||
contentContainerStyle={styles.discoveryContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.lexiconBanner, { backgroundColor: colors.primaryDark }]}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => router.push('/lexicon')}
|
||||
>
|
||||
<Ionicons name="book-outline" size={20} color={colors.iconOnImage} />
|
||||
<Text style={[styles.lexiconTitle, { color: colors.iconOnImage }]}>{t.lexiconTitle}</Text>
|
||||
<Text style={[styles.lexiconDesc, { color: colors.heroButton }]}>{t.lexiconDesc}</Text>
|
||||
<View style={[styles.lexiconBadge, { backgroundColor: colors.heroButtonBorder }]}>
|
||||
<Text style={[styles.lexiconBadgeText, { color: colors.iconOnImage }]}>{t.browseLexicon}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, paddingHorizontal: 20 },
|
||||
title: { fontSize: 23, fontWeight: '700', letterSpacing: 0.2, marginTop: 12, marginBottom: 16 },
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
gap: 10,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.14,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
searchInput: { flex: 1, fontSize: 15 },
|
||||
chipsList: { marginTop: 10, height: 50, maxHeight: 50 },
|
||||
chipsContent: { gap: 8, paddingRight: 4, paddingVertical: 1, alignItems: 'center' },
|
||||
catChip: {
|
||||
height: 40,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 0,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
},
|
||||
catChipText: {
|
||||
fontSize: 12.5,
|
||||
lineHeight: 18,
|
||||
fontWeight: '700',
|
||||
includeFontPadding: false,
|
||||
},
|
||||
deepSearchBtn: {
|
||||
marginTop: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-start',
|
||||
gap: 8,
|
||||
paddingHorizontal: 11,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.16,
|
||||
shadowRadius: 6,
|
||||
elevation: 2,
|
||||
},
|
||||
deepSearchWrap: {
|
||||
marginTop: 12,
|
||||
gap: 6,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
deepSearchText: { fontSize: 12, fontWeight: '700' },
|
||||
creditMetaRow: {
|
||||
gap: 1,
|
||||
marginLeft: 2,
|
||||
},
|
||||
creditMetaText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
results: { marginTop: 14 },
|
||||
resultsContent: { paddingBottom: 110 },
|
||||
sectionHeader: {
|
||||
marginBottom: 10,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
sectionTitle: { fontSize: 15, fontWeight: '600' },
|
||||
sectionCount: { fontSize: 13, fontWeight: '500' },
|
||||
grid: { marginBottom: 18 },
|
||||
gridRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: CARD_GAP,
|
||||
},
|
||||
cardWrapper: { width: CARD_WIDTH },
|
||||
cardSpacer: { width: CARD_WIDTH },
|
||||
emptyText: { marginBottom: 18, fontSize: 14, lineHeight: 20 },
|
||||
aiSection: { marginTop: 2 },
|
||||
aiStatusBlock: { marginBottom: 18 },
|
||||
aiLoadingRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 18 },
|
||||
aiStatusText: { fontSize: 13, fontWeight: '500' },
|
||||
managePlanBtn: {
|
||||
alignSelf: 'flex-start',
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
marginTop: -8,
|
||||
},
|
||||
managePlanText: { fontSize: 12, fontWeight: '700' },
|
||||
discoveryContent: { paddingTop: 16, paddingBottom: 120 },
|
||||
lexiconBanner: {
|
||||
marginTop: 8,
|
||||
padding: 18,
|
||||
borderRadius: 18,
|
||||
gap: 4,
|
||||
},
|
||||
lexiconTitle: { fontSize: 18, fontWeight: '700' },
|
||||
lexiconDesc: { fontSize: 12 },
|
||||
lexiconBadge: {
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 20,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
lexiconBadgeText: { fontSize: 11, fontWeight: '700' },
|
||||
});
|
||||
204
app/_layout.tsx
204
app/_layout.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
451
app/lexicon.tsx
Normal 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
254
app/onboarding.tsx
Normal 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
1443
app/plant/[id].tsx
Normal file
File diff suppressed because it is too large
Load Diff
657
app/profile/billing.tsx
Normal file
657
app/profile/billing.tsx
Normal 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
226
app/profile/data.tsx
Normal 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
229
app/profile/preferences.tsx
Normal 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
669
app/scanner.tsx
Normal 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 },
|
||||
});
|
||||
Reference in New Issue
Block a user