Launch
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
1692
app/(tabs)/index.tsx
1692
app/(tabs)/index.tsx
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
400
app/_layout.tsx
400
app/_layout.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
902
app/lexicon.tsx
902
app/lexicon.tsx
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
2886
app/plant/[id].tsx
2886
app/plant/[id].tsx
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
1359
app/scanner.tsx
1359
app/scanner.tsx
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user