This commit is contained in:
2026-03-29 10:26:38 -05:00
parent 05d4f6e78b
commit b1c99893a6
1628 changed files with 67782 additions and 60143 deletions

View File

@@ -1,58 +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>
);
}
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>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,204 +1,238 @@
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 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 { 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';
// 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 = AppMetaDb.get('install_marker_v2');
const secureMarker = await SecureStore.getItemAsync(SECURE_INSTALL_MARKER).catch(() => null);
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();
await AsyncStorage.removeItem('greenlens_show_tour');
AppMetaDb.set('install_marker_v2', '1');
await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1');
return true;
} catch (error) {
console.error('Failed to initialize install marker', error);
return false;
}
};
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
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 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 { 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';
// 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 = AppMetaDb.get('install_marker_v2');
const secureMarker = await SecureStore.getItemAsync(SECURE_INSTALL_MARKER).catch(() => null);
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();
await AsyncStorage.removeItem('greenlens_show_tour');
AppMetaDb.set('install_marker_v2', '1');
await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1');
return true;
} catch (error) {
console.error('Failed to initialize install marker', error);
return false;
}
};
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
function RootLayoutInner() {
const { isDarkMode, colorPalette, signOut, session, isInitializing, isLoadingPlants } = useApp();
const { isDarkMode, colorPalette, signOut, session, isInitializing, isLoadingPlants, syncRevenueCatState } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const pathname = usePathname();
const [installCheckDone, setInstallCheckDone] = useState(false);
const [splashAnimationComplete, setSplashAnimationComplete] = useState(false);
const [revenueCatReady, setRevenueCatReady] = useState(Constants.appOwnership === 'expo');
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';
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 });
}
setRevenueCatReady(true);
}, []);
useEffect(() => {
if (session?.serverUserId) {
posthog.identify(session.serverUserId, {
email: session.email,
name: session.name,
});
} else if (session === null) {
posthog.reset();
const isExpoGo = Constants.appOwnership === 'expo';
if (isExpoGo || !revenueCatReady) {
return;
}
}, [session, posthog]);
useEffect(() => {
let cancelled = false;
(async () => {
const didResetSessionForFreshInstall = await ensureInstallConsistency();
if (didResetSessionForFreshInstall) {
await signOut();
try {
if (session?.serverUserId) {
await Purchases.logIn(session.serverUserId);
const customerInfo = await Purchases.getCustomerInfo();
if (!cancelled) {
await syncRevenueCatState(customerInfo as any);
}
} else {
await Purchases.logOut();
}
} catch (error) {
console.error('Failed to align RevenueCat identity', error);
}
setInstallCheckDone(true);
})();
}, [signOut]);
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'} />
{content}
{!splashAnimationComplete && (
<AnimatedSplashScreen
isAppReady={isAppReady}
onAnimationComplete={() => setSplashAnimationComplete(true)}
/>
)}
</>
);
}
export default function RootLayout() {
initDatabase();
return (
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{
host: 'https://us.i.posthog.com',
enableSessionReplay: true,
}}>
<AppProvider>
<CoachMarksProvider>
<RootLayoutInner />
</CoachMarksProvider>
</AppProvider>
</PostHogProvider>
);
}
return () => {
cancelled = true;
};
}, [revenueCatReady, session?.serverUserId, syncRevenueCatState]);
useEffect(() => {
if (session?.serverUserId) {
posthog.identify(session.serverUserId, {
email: session.email,
name: session.name,
});
} else if (session === null) {
posthog.reset();
}
}, [session, posthog]);
useEffect(() => {
(async () => {
const didResetSessionForFreshInstall = await ensureInstallConsistency();
if (didResetSessionForFreshInstall) {
await signOut();
}
setInstallCheckDone(true);
})();
}, [signOut]);
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') || pathname.includes('profile/billing');
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.Screen
name="profile/billing"
options={{ presentation: 'card', animation: 'slide_from_right' }}
/>
</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'} />
{content}
{!splashAnimationComplete && (
<AnimatedSplashScreen
isAppReady={isAppReady}
onAnimationComplete={() => setSplashAnimationComplete(true)}
/>
)}
</>
);
}
export default function RootLayout() {
initDatabase();
return (
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{
host: 'https://us.i.posthog.com',
enableSessionReplay: true,
}}>
<AppProvider>
<CoachMarksProvider>
<RootLayoutInner />
</CoachMarksProvider>
</AppProvider>
</PostHogProvider>
);
}

View File

@@ -1,288 +1,288 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
ScrollView,
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';
import { AuthService } from '../../services/authService';
export default function LoginScreen() {
const { isDarkMode, colorPalette, hydrateSession } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async () => {
if (!email.trim() || !password) {
setError('Bitte alle Felder ausfüllen.');
return;
}
setLoading(true);
setError(null);
try {
const session = await AuthService.login(email, password);
await hydrateSession(session);
router.replace('/(tabs)');
} catch (e: any) {
if (e.message === 'USER_NOT_FOUND') {
setError('Kein Konto mit dieser E-Mail gefunden.');
} else if (e.message === 'WRONG_PASSWORD') {
setError('Falsches Passwort.');
} else if (e.message === 'BACKEND_URL_MISSING') {
setError('Backend-URL fehlt. Bitte EXPO_PUBLIC_BACKEND_URL konfigurieren.');
} else if (e.message === 'NETWORK_ERROR') {
setError('Server nicht erreichbar. Bitte versuche es erneut.');
} else {
setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.');
}
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: colors.background }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ThemeBackdrop colors={colors} />
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Logo / Header */}
<View style={styles.header}>
<Image
source={require('../../assets/icon.png')}
style={styles.logoIcon}
resizeMode="contain"
/>
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
Willkommen zurück
</Text>
</View>
{/* Card */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
{/* Email */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="deine@email.de"
placeholderTextColor={colors.textMuted}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
returnKeyType="next"
/>
</View>
</View>
{/* Password */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>Passwort</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="••••••••"
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoComplete="password"
returnKeyType="done"
onSubmitEditing={handleLogin}
/>
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<Ionicons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
{/* Error */}
{error && (
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
<Text style={[styles.errorText, { color: colors.danger }]}>{error}</Text>
</View>
)}
{/* Login Button */}
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.7 : 1 }]}
onPress={handleLogin}
activeOpacity={0.82}
disabled={loading}
>
{loading ? (
<ActivityIndicator color={colors.onPrimary} size="small" />
) : (
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>Anmelden</Text>
)}
</TouchableOpacity>
</View>
{/* Divider */}
<View style={styles.dividerRow}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>oder</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
{/* Sign Up Link */}
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => router.replace('/auth/signup')}
activeOpacity={0.82}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>
Noch kein Konto?{' '}
<Text style={{ color: colors.primary, fontWeight: '600' }}>Registrieren</Text>
</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
scroll: {
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 24,
paddingVertical: 48,
},
header: {
alignItems: 'center',
marginBottom: 32,
},
logoIcon: {
width: 56,
height: 56,
borderRadius: 14,
marginBottom: 16,
},
appName: {
fontSize: 30,
fontWeight: '700',
letterSpacing: -0.5,
marginBottom: 6,
},
subtitle: {
fontSize: 15,
fontWeight: '400',
},
card: {
borderRadius: 20,
borderWidth: 1,
padding: 24,
gap: 16,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 1,
shadowRadius: 12,
elevation: 4,
},
fieldGroup: {
gap: 6,
},
label: {
fontSize: 13,
fontWeight: '500',
marginLeft: 2,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
height: 50,
},
inputIcon: {
marginRight: 10,
},
input: {
flex: 1,
fontSize: 15,
height: 50,
},
eyeBtn: {
padding: 4,
marginLeft: 6,
},
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
},
errorText: {
fontSize: 13,
flex: 1,
},
primaryBtn: {
height: 52,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
marginTop: 4,
},
primaryBtnText: {
fontSize: 16,
fontWeight: '600',
},
dividerRow: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 20,
gap: 12,
},
dividerLine: {
flex: 1,
height: 1,
},
dividerText: {
fontSize: 13,
},
secondaryBtn: {
height: 52,
borderRadius: 14,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
secondaryBtnText: {
fontSize: 15,
},
});
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
ScrollView,
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';
import { AuthService } from '../../services/authService';
export default function LoginScreen() {
const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async () => {
if (!email.trim() || !password) {
setError(t.errFillAllFields);
return;
}
setLoading(true);
setError(null);
try {
const session = await AuthService.login(email, password);
await hydrateSession(session);
router.replace('/(tabs)');
} catch (e: any) {
if (e.message === 'USER_NOT_FOUND') {
setError(t.errUserNotFound);
} else if (e.message === 'WRONG_PASSWORD') {
setError(t.errWrongPassword);
} else if (e.message === 'BACKEND_URL_MISSING') {
setError(t.errNetworkError);
} else if (e.message === 'NETWORK_ERROR') {
setError(t.errNetworkError);
} else {
setError(t.errLoginFailed);
}
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: colors.background }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ThemeBackdrop colors={colors} />
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Logo / Header */}
<View style={styles.header}>
<Image
source={require('../../assets/icon.png')}
style={styles.logoIcon}
resizeMode="contain"
/>
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
{t.welcomeBack}
</Text>
</View>
{/* Card */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
{/* Email */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.emailPlaceholder}
placeholderTextColor={colors.textMuted}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
returnKeyType="next"
/>
</View>
</View>
{/* Password */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="••••••••"
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoComplete="password"
returnKeyType="done"
onSubmitEditing={handleLogin}
/>
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<Ionicons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
{/* Error */}
{error && (
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
<Text style={[styles.errorText, { color: colors.danger }]}>{error}</Text>
</View>
)}
{/* Login Button */}
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.7 : 1 }]}
onPress={handleLogin}
activeOpacity={0.82}
disabled={loading}
>
{loading ? (
<ActivityIndicator color={colors.onPrimary} size="small" />
) : (
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{t.onboardingLogin}</Text>
)}
</TouchableOpacity>
</View>
{/* Divider */}
<View style={styles.dividerRow}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
{/* Sign Up Link */}
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => router.replace('/auth/signup')}
activeOpacity={0.82}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>
{t.noAccountYet}{' '}
<Text style={{ color: colors.primary, fontWeight: '600' }}>{t.onboardingRegister}</Text>
</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
scroll: {
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 24,
paddingVertical: 48,
},
header: {
alignItems: 'center',
marginBottom: 32,
},
logoIcon: {
width: 56,
height: 56,
borderRadius: 14,
marginBottom: 16,
},
appName: {
fontSize: 30,
fontWeight: '700',
letterSpacing: -0.5,
marginBottom: 6,
},
subtitle: {
fontSize: 15,
fontWeight: '400',
},
card: {
borderRadius: 20,
borderWidth: 1,
padding: 24,
gap: 16,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 1,
shadowRadius: 12,
elevation: 4,
},
fieldGroup: {
gap: 6,
},
label: {
fontSize: 13,
fontWeight: '500',
marginLeft: 2,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
height: 50,
},
inputIcon: {
marginRight: 10,
},
input: {
flex: 1,
fontSize: 15,
height: 50,
},
eyeBtn: {
padding: 4,
marginLeft: 6,
},
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
},
errorText: {
fontSize: 13,
flex: 1,
},
primaryBtn: {
height: 52,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
marginTop: 4,
},
primaryBtnText: {
fontSize: 16,
fontWeight: '600',
},
dividerRow: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 20,
gap: 12,
},
dividerLine: {
flex: 1,
height: 1,
},
dividerText: {
fontSize: 13,
},
secondaryBtn: {
height: 52,
borderRadius: 14,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
secondaryBtnText: {
fontSize: 15,
},
});

View File

@@ -1,420 +1,420 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
ScrollView,
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';
import { AuthService } from '../../services/authService';
import AsyncStorage from '@react-native-async-storage/async-storage';
export default function SignupScreen() {
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const pendingPlant = getPendingPlant();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const validate = (): string | null => {
if (!name.trim()) return 'Bitte gib deinen Namen ein.';
if (!email.trim() || !email.includes('@')) return 'Bitte gib eine gültige E-Mail ein.';
if (password.length < 6) return 'Das Passwort muss mindestens 6 Zeichen haben.';
if (password !== passwordConfirm) return 'Die Passwörter stimmen nicht überein.';
return null;
};
const handleSignup = async () => {
const validationError = validate();
if (validationError) {
setError(validationError);
return;
}
setLoading(true);
setError(null);
try {
const session = await AuthService.signUp(email, name, password);
await hydrateSession(session);
// Flag setzen: Tour beim nächsten App-Öffnen anzeigen
await AsyncStorage.setItem('greenlens_show_tour', 'true');
router.replace('/(tabs)');
} catch (e: any) {
if (e.message === 'EMAIL_TAKEN') {
setError('Diese E-Mail ist bereits registriert.');
} else if (e.message === 'BACKEND_URL_MISSING') {
setError('Backend-URL fehlt. Bitte EXPO_PUBLIC_BACKEND_URL konfigurieren.');
} else if (e.message === 'NETWORK_ERROR') {
setError('Server nicht erreichbar. Bitte versuche es erneut.');
} else if (e.message === 'SERVER_ERROR') {
setError('Server-Fehler. Bitte versuche es später erneut.');
} else if (e.message === 'AUTH_ERROR') {
setError('Registrierung fehlgeschlagen. Bitte versuche es erneut.');
} else {
setError(`Fehler (${e.message}). Bitte versuche es erneut.`);
}
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: colors.background }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ThemeBackdrop colors={colors} />
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={[styles.backBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}
onPress={() => router.back()}
>
<Ionicons name="arrow-back" size={20} color={colors.text} />
</TouchableOpacity>
<Image
source={require('../../assets/icon.png')}
style={styles.logoIcon}
resizeMode="contain"
/>
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
Konto erstellen
</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 */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Dein Name"
placeholderTextColor={colors.textMuted}
value={name}
onChangeText={setName}
autoCapitalize="words"
autoComplete="name"
returnKeyType="next"
/>
</View>
</View>
{/* Email */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="deine@email.de"
placeholderTextColor={colors.textMuted}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
returnKeyType="next"
/>
</View>
</View>
{/* Password */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>Passwort</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Mindestens 6 Zeichen"
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoComplete="new-password"
returnKeyType="next"
/>
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<Ionicons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
{/* Password Confirm */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>Passwort bestätigen</Text>
<View style={[
styles.inputRow,
{
backgroundColor: colors.inputBg,
borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.inputBorder,
},
]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Passwort wiederholen"
placeholderTextColor={colors.textMuted}
value={passwordConfirm}
onChangeText={setPasswordConfirm}
secureTextEntry={!showPasswordConfirm}
autoComplete="new-password"
returnKeyType="done"
onSubmitEditing={handleSignup}
/>
<TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}>
<Ionicons
name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
{/* Password strength hint */}
{password.length > 0 && (
<View style={styles.strengthRow}>
{[1, 2, 3, 4].map((level) => (
<View
key={level}
style={[
styles.strengthBar,
{
backgroundColor:
password.length >= level * 3
? level <= 1
? colors.danger
: level === 2
? colors.warning
: colors.success
: colors.border,
},
]}
/>
))}
<Text style={[styles.strengthText, { color: colors.textMuted }]}>
{password.length < 4
? 'Zu kurz'
: password.length < 7
? 'Schwach'
: password.length < 10
? 'Mittel'
: 'Stark'}
</Text>
</View>
)}
{/* Error */}
{error && (
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
<Text style={[styles.errorText, { color: colors.danger }]}>{error}</Text>
</View>
)}
{/* Signup Button */}
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.7 : 1 }]}
onPress={handleSignup}
activeOpacity={0.82}
disabled={loading}
>
{loading ? (
<ActivityIndicator color={colors.onPrimary} size="small" />
) : (
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>Registrieren</Text>
)}
</TouchableOpacity>
</View>
{/* Login link */}
<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>
</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
scroll: {
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 24,
paddingVertical: 48,
},
header: {
alignItems: 'center',
marginBottom: 32,
},
backBtn: {
position: 'absolute',
left: 0,
top: 0,
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
logoIcon: {
width: 56,
height: 56,
borderRadius: 14,
marginBottom: 16,
},
appName: {
fontSize: 30,
fontWeight: '700',
letterSpacing: -0.5,
marginBottom: 6,
},
subtitle: {
fontSize: 15,
fontWeight: '400',
},
card: {
borderRadius: 20,
borderWidth: 1,
padding: 24,
gap: 14,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 1,
shadowRadius: 12,
elevation: 4,
},
fieldGroup: {
gap: 6,
},
label: {
fontSize: 13,
fontWeight: '500',
marginLeft: 2,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
height: 50,
},
inputIcon: {
marginRight: 10,
},
input: {
flex: 1,
fontSize: 15,
height: 50,
},
eyeBtn: {
padding: 4,
marginLeft: 6,
},
strengthRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: -4,
},
strengthBar: {
flex: 1,
height: 3,
borderRadius: 2,
},
strengthText: {
fontSize: 11,
marginLeft: 4,
width: 40,
},
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
},
errorText: {
fontSize: 13,
flex: 1,
},
primaryBtn: {
height: 52,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
marginTop: 4,
},
primaryBtnText: {
fontSize: 16,
fontWeight: '600',
},
loginLink: {
alignItems: 'center',
marginTop: 24,
paddingVertical: 8,
},
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,
},
});
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
ScrollView,
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';
import { AuthService } from '../../services/authService';
import AsyncStorage from '@react-native-async-storage/async-storage';
export default function SignupScreen() {
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const pendingPlant = getPendingPlant();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const validate = (): string | null => {
if (!name.trim()) return t.errNameRequired;
if (!email.trim() || !email.includes('@')) return t.errEmailInvalid;
if (password.length < 6) return t.errPasswordShort;
if (password !== passwordConfirm) return t.errPasswordMismatch;
return null;
};
const handleSignup = async () => {
const validationError = validate();
if (validationError) {
setError(validationError);
return;
}
setLoading(true);
setError(null);
try {
const session = await AuthService.signUp(email, name, password);
await hydrateSession(session);
// Flag setzen: Tour beim nächsten App-Öffnen anzeigen
await AsyncStorage.setItem('greenlens_show_tour', 'true');
router.replace('/(tabs)');
} catch (e: any) {
if (e.message === 'EMAIL_TAKEN') {
setError(t.errEmailTaken);
} else if (e.message === 'BACKEND_URL_MISSING') {
setError(t.errNetworkError);
} else if (e.message === 'NETWORK_ERROR') {
setError(t.errNetworkError);
} else if (e.message === 'SERVER_ERROR') {
setError(t.errServerError);
} else if (e.message === 'AUTH_ERROR') {
setError(t.errAuthError);
} else {
setError(t.errAuthError);
}
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: colors.background }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ThemeBackdrop colors={colors} />
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={[styles.backBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}
onPress={() => router.back()}
>
<Ionicons name="arrow-back" size={20} color={colors.text} />
</TouchableOpacity>
<Image
source={require('../../assets/icon.png')}
style={styles.logoIcon}
resizeMode="contain"
/>
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
{t.createAccount}
</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 }]}>
{t.pendingPlantHint.replace('{0}', pendingPlant.result.name)}
</Text>
</View>
)}
{/* Card */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
{/* Name */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.namePlaceholder}
placeholderTextColor={colors.textMuted}
value={name}
onChangeText={setName}
autoCapitalize="words"
autoComplete="name"
returnKeyType="next"
/>
</View>
</View>
{/* Email */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.emailPlaceholder}
placeholderTextColor={colors.textMuted}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
returnKeyType="next"
/>
</View>
</View>
{/* Password */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.passwordPlaceholder}
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoComplete="new-password"
returnKeyType="next"
/>
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<Ionicons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
{/* Password Confirm */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.confirmPasswordLabel}</Text>
<View style={[
styles.inputRow,
{
backgroundColor: colors.inputBg,
borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.inputBorder,
},
]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.confirmPasswordPlaceholder}
placeholderTextColor={colors.textMuted}
value={passwordConfirm}
onChangeText={setPasswordConfirm}
secureTextEntry={!showPasswordConfirm}
autoComplete="new-password"
returnKeyType="done"
onSubmitEditing={handleSignup}
/>
<TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}>
<Ionicons
name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
{/* Password strength hint */}
{password.length > 0 && (
<View style={styles.strengthRow}>
{[1, 2, 3, 4].map((level) => (
<View
key={level}
style={[
styles.strengthBar,
{
backgroundColor:
password.length >= level * 3
? level <= 1
? colors.danger
: level === 2
? colors.warning
: colors.success
: colors.border,
},
]}
/>
))}
<Text style={[styles.strengthText, { color: colors.textMuted }]}>
{password.length < 4
? t.strengthTooShort
: password.length < 7
? t.strengthWeak
: password.length < 10
? t.strengthMedium
: t.strengthStrong}
</Text>
</View>
)}
{/* Error */}
{error && (
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
<Text style={[styles.errorText, { color: colors.danger }]}>{error}</Text>
</View>
)}
{/* Signup Button */}
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.7 : 1 }]}
onPress={handleSignup}
activeOpacity={0.82}
disabled={loading}
>
{loading ? (
<ActivityIndicator color={colors.onPrimary} size="small" />
) : (
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{t.onboardingRegister}</Text>
)}
</TouchableOpacity>
</View>
{/* Login link */}
<TouchableOpacity style={styles.loginLink} onPress={() => router.replace('/auth/login')}>
<Text style={[styles.loginLinkText, { color: colors.textSecondary }]}>
{t.alreadyHaveAccount}{' '}
<Text style={{ color: colors.primary, fontWeight: '600' }}>{t.onboardingLogin}</Text>
</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
scroll: {
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 24,
paddingVertical: 48,
},
header: {
alignItems: 'center',
marginBottom: 32,
},
backBtn: {
position: 'absolute',
left: 0,
top: 0,
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
logoIcon: {
width: 56,
height: 56,
borderRadius: 14,
marginBottom: 16,
},
appName: {
fontSize: 30,
fontWeight: '700',
letterSpacing: -0.5,
marginBottom: 6,
},
subtitle: {
fontSize: 15,
fontWeight: '400',
},
card: {
borderRadius: 20,
borderWidth: 1,
padding: 24,
gap: 14,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 1,
shadowRadius: 12,
elevation: 4,
},
fieldGroup: {
gap: 6,
},
label: {
fontSize: 13,
fontWeight: '500',
marginLeft: 2,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
height: 50,
},
inputIcon: {
marginRight: 10,
},
input: {
flex: 1,
fontSize: 15,
height: 50,
},
eyeBtn: {
padding: 4,
marginLeft: 6,
},
strengthRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: -4,
},
strengthBar: {
flex: 1,
height: 3,
borderRadius: 2,
},
strengthText: {
fontSize: 11,
marginLeft: 4,
width: 40,
},
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
},
errorText: {
fontSize: 13,
flex: 1,
},
primaryBtn: {
height: 52,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
marginTop: 4,
},
primaryBtnText: {
fontSize: 16,
fontWeight: '600',
},
loginLink: {
alignItems: 'center',
marginTop: 24,
paddingVertical: 8,
},
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,
},
});

View File

@@ -1,451 +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,
},
});
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,
},
});

View File

@@ -1,254 +1,278 @@
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,
},
});
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={18} 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>
<TouchableOpacity
style={[styles.plansBtn, { borderColor: colors.primary }]}
onPress={() => router.push('/profile/billing')}
activeOpacity={0.82}
>
<Ionicons name="pricetag-outline" size={16} color={colors.primary} />
<Text style={[styles.plansBtnText, { color: colors.primary }]}>
View Subscription Plans & Pricing
</Text>
</TouchableOpacity>
<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: 8,
flex: 1,
justifyContent: 'center',
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 16,
borderWidth: 1,
},
featureIcon: {
width: 36,
height: 36,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
},
featureText: {
flex: 1,
fontSize: 13,
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',
},
plansBtn: {
height: 48,
borderRadius: 16,
borderWidth: 1.5,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
},
plansBtnText: {
fontSize: 14,
fontWeight: '600',
},
disclaimer: {
fontSize: 12,
textAlign: 'center',
opacity: 0.6,
marginTop: 8,
},
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,226 +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,
},
});
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,
},
});

View File

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

File diff suppressed because it is too large Load Diff