Onboarding

This commit is contained in:
2026-05-08 13:00:30 +02:00
parent d37b49f1f6
commit 9386ae1be7
37 changed files with 5606 additions and 2275 deletions

View File

@@ -1,58 +1,59 @@
import { useEffect, useRef, useState } from 'react';
import { Animated, AppState, 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, AppState, Easing, Image, StyleSheet, Text, View } from 'react-native';
import { Redirect, Stack, usePathname, useRouter } from 'expo-router';
import { useShareIntent } from 'expo-share-intent';
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,
@@ -66,96 +67,117 @@ function RootLayoutInner() {
isLoadingBilling,
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';
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(() => {
const isExpoGo = Constants.appOwnership === 'expo';
if (isExpoGo || !revenueCatReady) {
return;
}
let cancelled = false;
(async () => {
try {
if (session?.serverUserId) {
await Purchases.logIn(session.serverUserId);
const customerInfo = await Purchases.getCustomerInfo();
if (!cancelled) {
await syncRevenueCatState(customerInfo as any, 'app_init');
}
} else {
await Purchases.logOut();
}
} catch (error) {
console.error('Failed to align RevenueCat identity', error);
}
})();
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(() => {
posthog.capture('screen_viewed', { screen: pathname });
}, [pathname, posthog]);
useEffect(() => {
posthog.capture('app_opened');
const subscription = AppState.addEventListener('change', (nextState) => {
if (nextState === 'active') {
posthog.capture('app_opened');
}
});
return () => subscription.remove();
}, [posthog]);
useEffect(() => {
(async () => {
const didResetSessionForFreshInstall = await ensureInstallConsistency();
if (didResetSessionForFreshInstall) {
await signOut();
}
setInstallCheckDone(true);
})();
}, [signOut]);
const colors = useColors(isDarkMode, colorPalette);
const pathname = usePathname();
const router = useRouter();
const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent();
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';
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(() => {
const isExpoGo = Constants.appOwnership === 'expo';
if (isExpoGo || !revenueCatReady) {
return;
}
let cancelled = false;
(async () => {
try {
if (session?.serverUserId) {
await Purchases.logIn(session.serverUserId);
const customerInfo = await Purchases.getCustomerInfo();
if (!cancelled) {
await syncRevenueCatState(customerInfo as any, 'app_init');
}
} else {
await Purchases.logOut();
}
} catch (error) {
console.error('Failed to align RevenueCat identity', error);
}
})();
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(() => {
posthog.capture('screen_viewed', { screen: pathname });
}, [pathname, posthog]);
useEffect(() => {
posthog.capture('app_opened');
const subscription = AppState.addEventListener('change', (nextState) => {
if (nextState === 'active') {
posthog.capture('app_opened');
}
});
return () => subscription.remove();
}, [posthog]);
useEffect(() => {
(async () => {
const didResetSessionForFreshInstall = await ensureInstallConsistency();
if (didResetSessionForFreshInstall) {
await signOut();
}
setInstallCheckDone(true);
})();
}, [signOut]);
const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
useEffect(() => {
if (!hasShareIntent || !isAppReady) return;
const sharedImage = shareIntent.files?.find((file) => file.mimeType?.startsWith('image/'));
if (!sharedImage) {
resetShareIntent();
return;
}
const uri = sharedImage.path;
if (!uri) {
resetShareIntent();
return;
}
resetShareIntent();
router.push({
pathname: '/scanner',
params: { sharedImageUri: uri },
});
}, [hasShareIntent, shareIntent, resetShareIntent, router, isAppReady]);
const hasActiveEntitlement = isActivatingEntitlement
|| (billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active');
@@ -167,22 +189,22 @@ function RootLayoutInner() {
|| pathname.includes('onboarding')
|| pathname.includes('scanner')
|| pathname.includes('profile/billing');
let content = null;
if (isAppReady) {
let content = null;
if (isAppReady) {
if (!session) {
// Only redirect if we are not already on an auth-related page or the scanner
if (!isAllowedWithoutSession) {
content = <Redirect href="/onboarding" />;
} else {
content = (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
}}
>
content = (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
<Stack.Screen name="onboarding/source" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/goal" options={{ animation: 'slide_from_right' }} />
@@ -190,28 +212,28 @@ function RootLayoutInner() {
<Stack.Screen name="onboarding/customize" options={{ animation: 'slide_from_right' }} />
<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>
<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 if (!hasActiveEntitlement && !isLoadingBilling && !isAllowedWithoutEntitlement) {
content = <Redirect href="/profile/billing" />;
content = <Redirect href="/onboarding" />;
} else {
content = (
<>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
}}
>
content = (
<>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
<Stack.Screen name="onboarding/source" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/goal" options={{ animation: 'slide_from_right' }} />
@@ -219,65 +241,65 @@ function RootLayoutInner() {
<Stack.Screen name="onboarding/customize" options={{ animation: 'slide_from_right' }} />
<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 (
<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: false,
}}>
<AppProvider>
<CoachMarksProvider>
<RootLayoutInner />
</CoachMarksProvider>
</AppProvider>
</PostHogProvider>
);
}
<AppProvider>
<CoachMarksProvider>
<RootLayoutInner />
</CoachMarksProvider>
</AppProvider>
</PostHogProvider>
);
}

View File

@@ -14,18 +14,25 @@ import {
import AsyncStorage from '@react-native-async-storage/async-storage';
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 { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { AuthService } from '../../services/authService';
import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { usePostHog } from 'posthog-react-native';
const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
export default function LoginScreen() {
const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = usePostHog();
const screenBackground = isDarkMode
? ONBOARDING_AUTH_BACKGROUND.dark
: ONBOARDING_AUTH_BACKGROUND.light;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -112,7 +119,7 @@ export default function LoginScreen() {
await AsyncStorage.setItem('greenlens_show_tour', 'true');
}
posthog.capture('apple_login_succeeded', { surface: 'login' });
router.replace(session.isNewUser ? '/profile/billing' : '/(tabs)');
router.replace(session.isNewUser ? '/onboarding/source' : '/(tabs)');
} catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') {
return;
@@ -130,21 +137,26 @@ export default function LoginScreen() {
};
return (
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: colors.background }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ThemeBackdrop colors={colors} />
<ScrollView
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: screenBackground }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Logo / Header */}
<View style={styles.header}>
<Image
source={require('../../assets/icon.png')}
style={styles.logoIcon}
{/* Logo / 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>
@@ -274,16 +286,27 @@ const styles = StyleSheet.create({
paddingHorizontal: 24,
paddingVertical: 48,
},
header: {
alignItems: 'center',
marginBottom: 32,
},
logoIcon: {
width: 56,
height: 56,
borderRadius: 14,
marginBottom: 16,
},
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: 84,
height: 84,
borderRadius: 20,
marginBottom: 16,
},
appName: {
fontSize: 30,
fontWeight: '700',

View File

@@ -12,21 +12,28 @@ import {
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 { router } from 'expo-router';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { AuthService } from '../../services/authService';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { usePostHog } from 'posthog-react-native';
const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
export default function SignupScreen() {
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = usePostHog();
const pendingPlant = getPendingPlant();
const screenBackground = isDarkMode
? ONBOARDING_AUTH_BACKGROUND.dark
: ONBOARDING_AUTH_BACKGROUND.light;
const [name, setName] = useState('');
const [email, setEmail] = useState('');
@@ -78,7 +85,7 @@ export default function SignupScreen() {
await hydrateSession(session);
// Flag setzen: Tour beim nächsten App-Öffnen anzeigen
await AsyncStorage.setItem('greenlens_show_tour', 'true');
router.replace('/profile/billing');
router.replace('/onboarding/source');
} catch (e: any) {
if (e.message === 'EMAIL_TAKEN') {
setError(t.errEmailTaken);
@@ -127,7 +134,7 @@ export default function SignupScreen() {
await hydrateSession(session);
await AsyncStorage.setItem('greenlens_show_tour', 'true');
posthog.capture('apple_login_succeeded', { surface: 'signup' });
router.replace(session.isNewUser ? '/profile/billing' : '/(tabs)');
router.replace(session.isNewUser ? '/onboarding/source' : '/(tabs)');
} catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') {
return;
@@ -145,12 +152,11 @@ export default function SignupScreen() {
};
return (
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: colors.background }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ThemeBackdrop colors={colors} />
<ScrollView
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: screenBackground }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
@@ -391,12 +397,12 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
logoIcon: {
width: 56,
height: 56,
borderRadius: 14,
marginBottom: 16,
},
logoIcon: {
width: 84,
height: 84,
borderRadius: 20,
marginBottom: 16,
},
appName: {
fontSize: 30,
fontWeight: '700',

View File

@@ -1,278 +1,367 @@
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,
},
});
import React from 'react';
import {
Image,
ImageBackground,
SafeAreaView,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import Svg, { Path } from 'react-native-svg';
import { useApp } from '../context/AppContext';
type Feature = {
icon: keyof typeof Ionicons.glyphMap;
title: string;
description: string;
};
export default function OnboardingScreen() {
const { t } = useApp();
const { height, width } = useWindowDimensions();
const compact = height < 760;
const sheetTop = compact ? 142 : 156;
const waveHeight = compact ? 148 : 170;
const bodyOffset = waveHeight - 2;
const contentTop = compact ? 94 : 108;
const features: Feature[] = [
{
icon: 'scan-outline',
title: t.welcomeFeatureIdentifyTitle,
description: t.welcomeFeatureIdentifyDesc,
},
{
icon: 'notifications-outline',
title: t.welcomeFeatureReminderTitle,
description: t.welcomeFeatureReminderDesc,
},
{
icon: 'book-outline',
title: t.welcomeFeatureLibraryTitle,
description: t.welcomeFeatureLibraryDesc,
},
];
return (
<View style={styles.container}>
<ImageBackground
source={require('../assets/welcome_botanical_hero.png')}
style={styles.heroImage}
imageStyle={styles.heroImageContent}
resizeMode="cover"
>
<View style={styles.heroShadeTop} />
<View style={styles.heroShadeBottom} />
<SafeAreaView style={styles.safeArea}>
<View style={[styles.brandRow, compact && styles.brandRowCompact]}>
<Image
source={require('../assets/icon.png')}
style={styles.logo}
resizeMode="cover"
/>
<Text style={styles.brandName}>
Green<Text style={styles.brandAccent}>Lens</Text>
</Text>
</View>
</SafeAreaView>
</ImageBackground>
<View style={[styles.sheet, { top: sheetTop }]}>
<Svg
width={width}
height={waveHeight}
viewBox={`0 0 ${width} ${waveHeight}`}
preserveAspectRatio="none"
style={styles.sheetWave}
>
<Path
d={`M0 34 C ${width * 0.08} 76 ${width * 0.14} 82 ${width * 0.24} 82 C ${width * 0.38} 82 ${width * 0.52} 82 ${width * 0.64} 82 C ${width * 0.78} 86 ${width * 0.88} 132 ${width} 156 L ${width} ${waveHeight} L 0 ${waveHeight} Z`}
fill="#fbfaf3"
/>
</Svg>
<View style={[styles.sheetBody, { top: bodyOffset }]} />
<View
style={[
styles.sheetContent,
{ top: contentTop },
compact && styles.sheetContentCompact,
]}
>
<Text style={[styles.headline, compact && styles.headlineCompact]}>
{t.welcomeHeadline}
</Text>
<Text style={styles.subheadline}>{t.welcomeSubheadline}</Text>
<View style={styles.features}>
{features.map((feature, index) => (
<View
key={feature.title}
style={[
styles.featureRow,
index === features.length - 1 && styles.featureRowLast,
]}
>
<View style={styles.featureIcon}>
<Ionicons name={feature.icon} size={22} color="#a6d66f" />
</View>
<View style={styles.featureCopy}>
<Text style={styles.featureTitle}>{feature.title}</Text>
<Text style={styles.featureDescription}>{feature.description}</Text>
</View>
</View>
))}
</View>
<TouchableOpacity
style={styles.demoButton}
onPress={() => router.push('/scanner')}
activeOpacity={0.86}
>
<Ionicons name="scan" size={25} color="#f8f7ef" />
<Text style={styles.demoButtonText}>{t.welcomeDemoScan}</Text>
<Ionicons name="chevron-forward" size={26} color="#f8f7ef" />
</TouchableOpacity>
<View style={styles.authRow}>
<TouchableOpacity
style={styles.authButton}
onPress={() => router.push('/auth/signup')}
activeOpacity={0.82}
>
<Text style={styles.authButtonText}>{t.onboardingRegister}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.authButton, styles.loginButton]}
onPress={() => router.push('/auth/login')}
activeOpacity={0.82}
>
<Text style={[styles.authButtonText, styles.loginButtonText]}>
{t.onboardingLogin}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.subscriptionLink}
onPress={() => router.push('/profile/billing')}
activeOpacity={0.8}
>
<Ionicons name="leaf-outline" size={21} color="#4b7c31" />
<Text style={styles.subscriptionText}>{t.welcomeSubscriptionPlans}</Text>
<Ionicons name="chevron-forward" size={20} color="#4b7c31" />
</TouchableOpacity>
<Text style={styles.legalText}>{t.welcomeLegal}</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0a110b',
},
heroImage: {
height: '60%',
minHeight: 430,
},
heroImageContent: {
backgroundColor: '#0a110b',
transform: [{ scale: 1.04 }],
},
heroShadeTop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.08)',
},
heroShadeBottom: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 190,
backgroundColor: 'rgba(7,12,7,0.2)',
},
safeArea: {
flex: 1,
},
brandRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 15,
paddingHorizontal: 30,
paddingTop: 62,
},
brandRowCompact: {
paddingTop: 42,
},
logo: {
width: 68,
height: 68,
borderRadius: 16,
backgroundColor: '#fff',
},
brandName: {
color: '#f8f7ef',
fontSize: 36,
fontWeight: '900',
},
brandAccent: {
color: '#9bc76e',
},
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
},
sheetWave: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
},
sheetBody: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: '#fbfaf3',
},
sheetContent: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 24,
paddingBottom: 10,
},
sheetContentCompact: {
paddingHorizontal: 22,
paddingBottom: 8,
},
headline: {
color: '#101c12',
fontSize: 40,
lineHeight: 43,
fontWeight: '900',
marginBottom: 6,
maxWidth: 310,
},
headlineCompact: {
fontSize: 34,
lineHeight: 37,
},
subheadline: {
color: '#5f625d',
fontSize: 15,
lineHeight: 19,
fontWeight: '500',
marginBottom: 11,
},
features: {
marginBottom: 10,
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 11,
paddingVertical: 6,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(16,28,18,0.14)',
},
featureRowLast: {
borderBottomWidth: 0,
},
featureIcon: {
width: 46,
height: 46,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#173817',
},
featureCopy: {
flex: 1,
},
featureTitle: {
color: '#101c12',
fontSize: 16,
fontWeight: '800',
marginBottom: 2,
},
featureDescription: {
color: '#696b65',
fontSize: 13,
lineHeight: 16,
fontWeight: '500',
},
demoButton: {
height: 60,
borderRadius: 7,
backgroundColor: '#437824',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 18,
marginBottom: 8,
},
demoButtonText: {
color: '#f8f7ef',
fontSize: 21,
fontWeight: '800',
},
authRow: {
flexDirection: 'row',
gap: 8,
marginBottom: 8,
},
authButton: {
flex: 1,
height: 50,
borderRadius: 7,
borderWidth: 1.4,
borderColor: '#4b7c31',
alignItems: 'center',
justifyContent: 'center',
},
loginButton: {
borderColor: '#101c12',
},
authButtonText: {
color: '#4b7c31',
fontSize: 17,
fontWeight: '700',
},
loginButtonText: {
color: '#101c12',
},
subscriptionLink: {
minHeight: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
marginBottom: 7,
},
subscriptionText: {
color: '#4b7c31',
fontSize: 15,
fontWeight: '800',
textAlign: 'center',
},
legalText: {
color: '#6b6d68',
fontSize: 11,
lineHeight: 14,
fontWeight: '500',
textAlign: 'center',
},
});

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@@ -9,18 +9,61 @@ import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService';
const ONBOARDING_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
const EXPERIENCE_OPTIONS = [
{ id: 'beginner', icon: 'leaf-outline' as const },
{ id: 'intermediate', icon: 'sunny-outline' as const },
{ id: 'advanced', icon: 'flask-outline' as const },
];
const getExperienceScreenCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
step: 'Schritt 3 von 4',
heroBadge: 'Pflege-Tiefe',
subtitles: {
beginner: 'Klare Sprache, sichere Defaults, weniger Fachbegriffe.',
intermediate: 'Praktische Schritte mit genug Kontext.',
advanced: 'Mehr botanische Details und engere Diagnose.',
},
};
}
if (language === 'es') {
return {
step: 'Paso 3 de 4',
heroBadge: 'Nivel de cuidado',
subtitles: {
beginner: 'Lenguaje claro y recomendaciones seguras.',
intermediate: 'Pasos practicos con suficiente contexto.',
advanced: 'Mas detalle botanico y diagnostico preciso.',
},
};
}
return {
step: 'Step 3 of 4',
heroBadge: 'Care depth',
subtitles: {
beginner: 'Clear language, fewer assumptions, safer defaults.',
intermediate: 'Practical care steps with enough detail.',
advanced: 'More botanical context and tighter diagnosis.',
},
};
};
export default function OnboardingExperienceScreen() {
const router = useRouter();
const posthog = usePostHog();
const { session, isDarkMode, colorPalette, t } = useApp();
const { session, isDarkMode, colorPalette, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;
const [selectedLevel, setSelectedLevel] = useState<string | null>(null);
const copy = getExperienceScreenCopy(language);
const levelLabels = useMemo(
() => ({
@@ -39,17 +82,29 @@ export default function OnboardingExperienceScreen() {
posthog.capture('onboarding_experience_completed', {
experience_level: level ?? 'skipped',
});
router.replace('/(tabs)');
router.replace('/onboarding/health-check');
};
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ThemeBackdrop colors={colors} />
<View style={[styles.container, { backgroundColor: screenBackground }]}>
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}>
<View style={[styles.headerIcon, { backgroundColor: colors.primarySoft }]}>
<Ionicons name="sparkles-outline" size={26} color={colors.primaryDark} />
<View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
</View>
<ImageBackground
source={require('../../assets/onboarding_experience_mockup.png')}
style={[styles.heroPreview, { borderColor: colors.border }]}
imageStyle={styles.heroImage}
resizeMode="cover"
>
<View style={[styles.heroOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.4)' : 'rgba(251, 250, 243, 0.24)' }]} />
<View style={[styles.heroMetric, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="sparkles-outline" size={18} color={colors.primary} />
<Text style={[styles.heroMetricText, { color: colors.text }]}>{copy.heroBadge}</Text>
</View>
</ImageBackground>
<Text style={[styles.title, { color: colors.text }]}>{t.experienceOnboardingTitle}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.experienceOnboardingSubtitle}</Text>
</View>
@@ -73,7 +128,12 @@ export default function OnboardingExperienceScreen() {
<View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}>
<Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} />
</View>
<Text style={[styles.optionLabel, { color: colors.text }]}>{levelLabels[option.id as keyof typeof levelLabels]}</Text>
<View style={styles.optionCopy}>
<Text style={[styles.optionLabel, { color: colors.text }]}>{levelLabels[option.id as keyof typeof levelLabels]}</Text>
<Text style={[styles.optionSubtitle, { color: colors.textMuted }]}>
{copy.subtitles[option.id as keyof typeof copy.subtitles]}
</Text>
</View>
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} />}
</TouchableOpacity>
);
@@ -104,26 +164,35 @@ export default function OnboardingExperienceScreen() {
const styles = StyleSheet.create({
container: { flex: 1 },
safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 },
header: { alignItems: 'center', gap: 10, marginBottom: 28 },
headerIcon: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center' },
title: { fontSize: 28, fontWeight: '800', textAlign: 'center', lineHeight: 32 },
subtitle: { fontSize: 14, textAlign: 'center', lineHeight: 20, maxWidth: 320 },
options: { gap: 12, flex: 1 },
safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 12, paddingBottom: 14 },
header: { alignItems: 'center', gap: 9, marginBottom: 14 },
stepPill: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 },
stepLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.4 },
heroPreview: { width: '100%', height: 175, borderRadius: 24, borderWidth: 1, overflow: 'hidden', justifyContent: 'flex-end', alignItems: 'flex-start' },
heroImage: { borderRadius: 24 },
heroOverlay: { ...StyleSheet.absoluteFillObject },
heroMetric: { margin: 12, borderRadius: 999, borderWidth: 1, paddingHorizontal: 11, paddingVertical: 7, flexDirection: 'row', alignItems: 'center', gap: 6 },
heroMetricText: { fontSize: 12, fontWeight: '800' },
title: { fontSize: 25, fontWeight: '800', textAlign: 'center', lineHeight: 29 },
subtitle: { fontSize: 13, textAlign: 'center', lineHeight: 18, maxWidth: 320 },
options: { gap: 8, flex: 1 },
optionCard: {
minHeight: 64,
borderRadius: 18,
flex: 1,
borderRadius: 15,
borderWidth: 1.5,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
gap: 12,
paddingHorizontal: 14,
paddingVertical: 16,
gap: 10,
},
optionIcon: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
optionLabel: { flex: 1, fontSize: 15, fontWeight: '600' },
footer: { flexDirection: 'row', gap: 12, marginTop: 16 },
secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
optionIcon: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
optionCopy: { flex: 1, gap: 3 },
optionLabel: { fontSize: 14, fontWeight: '700' },
optionSubtitle: { fontSize: 10.5, lineHeight: 14 },
footer: { flexDirection: 'row', gap: 12, marginTop: 10 },
secondaryBtn: { flex: 1, height: 50, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
secondaryBtnText: { fontSize: 15, fontWeight: '600' },
primaryBtn: { flex: 1.2, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtn: { flex: 1.2, height: 50, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtnText: { fontSize: 15, fontWeight: '700' },
});

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@@ -9,6 +9,11 @@ import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService';
const ONBOARDING_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
const GOAL_OPTIONS = [
{ id: 'identify', icon: 'scan-outline' as const },
{ id: 'care', icon: 'water-outline' as const },
@@ -16,12 +21,53 @@ const GOAL_OPTIONS = [
{ id: 'learn', icon: 'book-outline' as const },
];
const getGoalScreenCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
step: 'Schritt 2 von 4',
heroBadge: 'Erstes Ziel',
subtitles: {
identify: 'Schnell erkennen, Pflege danach klaeren.',
care: 'Aus Symptomen konkrete Schritte machen.',
collection: 'Eine saubere Pflanzenbibliothek aufbauen.',
learn: 'Pflanzenwissen einfacher einsortieren.',
},
};
}
if (language === 'es') {
return {
step: 'Paso 2 de 4',
heroBadge: 'Primer objetivo',
subtitles: {
identify: 'Respuesta rapida primero, cuidado despues.',
care: 'Convertir sintomas en pasos claros.',
collection: 'Crear una biblioteca de plantas ordenada.',
learn: 'Aprender plantas con explicaciones simples.',
},
};
}
return {
step: 'Step 2 of 4',
heroBadge: 'First goal',
subtitles: {
identify: 'Fast answer first, care details after.',
care: 'Turn symptoms into a clear next step.',
collection: 'Build a tidy plant library over time.',
learn: 'Browse plants with simpler explanations.',
},
};
};
export default function OnboardingGoalScreen() {
const router = useRouter();
const posthog = usePostHog();
const { session, isDarkMode, colorPalette, t } = useApp();
const { session, isDarkMode, colorPalette, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;
const [selectedGoal, setSelectedGoal] = useState<string | null>(null);
const copy = getGoalScreenCopy(language);
const goalLabels = useMemo(
() => ({
@@ -45,13 +91,25 @@ export default function OnboardingGoalScreen() {
};
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ThemeBackdrop colors={colors} />
<View style={[styles.container, { backgroundColor: screenBackground }]}>
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}>
<View style={[styles.headerIcon, { backgroundColor: colors.primarySoft }]}>
<Ionicons name="flag-outline" size={26} color={colors.primaryDark} />
<View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
</View>
<ImageBackground
source={require('../../assets/onboarding_goal_mockup.png')}
style={[styles.heroPreview, { borderColor: colors.border }]}
imageStyle={styles.heroImage}
resizeMode="cover"
>
<View style={[styles.heroOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.22)' : 'rgba(251, 250, 243, 0.28)' }]} />
<View style={[styles.heroBadge, { backgroundColor: colors.primary }]}>
<Ionicons name="flag-outline" size={16} color={colors.onPrimary} />
<Text style={[styles.heroBadgeText, { color: colors.onPrimary }]}>{copy.heroBadge}</Text>
</View>
</ImageBackground>
<Text style={[styles.title, { color: colors.text }]}>{t.goalOnboardingTitle}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.goalOnboardingSubtitle}</Text>
</View>
@@ -75,7 +133,12 @@ export default function OnboardingGoalScreen() {
<View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}>
<Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} />
</View>
<Text style={[styles.optionLabel, { color: colors.text }]}>{goalLabels[option.id as keyof typeof goalLabels]}</Text>
<View style={styles.optionCopy}>
<Text style={[styles.optionLabel, { color: colors.text }]}>{goalLabels[option.id as keyof typeof goalLabels]}</Text>
<Text style={[styles.optionSubtitle, { color: colors.textMuted }]}>
{copy.subtitles[option.id as keyof typeof copy.subtitles]}
</Text>
</View>
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} />}
</TouchableOpacity>
);
@@ -106,26 +169,35 @@ export default function OnboardingGoalScreen() {
const styles = StyleSheet.create({
container: { flex: 1 },
safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 },
header: { alignItems: 'center', gap: 10, marginBottom: 28 },
headerIcon: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center' },
title: { fontSize: 28, fontWeight: '800', textAlign: 'center', lineHeight: 32 },
subtitle: { fontSize: 14, textAlign: 'center', lineHeight: 20, maxWidth: 320 },
options: { gap: 12, flex: 1 },
safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 12, paddingBottom: 14 },
header: { alignItems: 'center', gap: 9, marginBottom: 14 },
stepPill: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 },
stepLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.4 },
heroPreview: { width: '100%', height: 175, borderRadius: 24, borderWidth: 1, overflow: 'hidden', justifyContent: 'flex-end', alignItems: 'flex-start' },
heroImage: { borderRadius: 24 },
heroOverlay: { ...StyleSheet.absoluteFillObject },
heroBadge: { margin: 12, borderRadius: 999, paddingHorizontal: 11, paddingVertical: 7, flexDirection: 'row', alignItems: 'center', gap: 6 },
heroBadgeText: { fontSize: 12, fontWeight: '800' },
title: { fontSize: 25, fontWeight: '800', textAlign: 'center', lineHeight: 29 },
subtitle: { fontSize: 13, textAlign: 'center', lineHeight: 18, maxWidth: 320 },
options: { gap: 8, flex: 1 },
optionCard: {
minHeight: 64,
borderRadius: 18,
flex: 1,
borderRadius: 15,
borderWidth: 1.5,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
gap: 12,
paddingHorizontal: 14,
paddingVertical: 12,
gap: 10,
},
optionIcon: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
optionLabel: { flex: 1, fontSize: 15, fontWeight: '600' },
footer: { flexDirection: 'row', gap: 12, marginTop: 16 },
secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
optionIcon: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
optionCopy: { flex: 1, gap: 3 },
optionLabel: { fontSize: 14, fontWeight: '700' },
optionSubtitle: { fontSize: 10.5, lineHeight: 14 },
footer: { flexDirection: 'row', gap: 12, marginTop: 10 },
secondaryBtn: { flex: 1, height: 50, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
secondaryBtnText: { fontSize: 15, fontWeight: '600' },
primaryBtn: { flex: 1.2, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtn: { flex: 1.2, height: 50, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtnText: { fontSize: 15, fontWeight: '700' },
});

View File

@@ -0,0 +1,202 @@
import React from 'react';
import { ImageBackground, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { usePostHog } from 'posthog-react-native';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext';
const ONBOARDING_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
const getHealthOnboardingCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
step: 'Schritt 4 von 4',
title: 'Wo ist der Health-Scan?',
subtitle: 'Du findest ihn auf jeder gespeicherten Pflanze, direkt unter der Beschreibung.',
buttonPreview: 'Health-Scan starten',
cta: 'Weiter',
skip: 'Spaeter',
flow: ['Pflanze scannen', 'Speichern', 'Detailseite oeffnen', 'Health-Scan starten'],
outputTitle: 'Was du danach bekommst',
outputs: [
'Gesundheits-Score mit Status: stabil, beobachten oder kritisch.',
'Ausfuehrliche Analyse mit sichtbaren Hinweisen und Unsicherheit.',
'Wahrscheinlichste Ursachen mit Confidence-Werten.',
'Sofortmassnahmen plus konkreter 7-Tage-Pflegeplan.',
],
guidanceNote: 'Tipp: Fotografiere die ganze Pflanze, die Blattunterseiten und die Erde. Je klarer das Foto, desto genauer wird der Plan.',
};
}
if (language === 'es') {
return {
step: 'Paso 4 de 4',
title: 'Donde esta el health-scan?',
subtitle: 'Lo encuentras en cada planta guardada, justo debajo de la descripcion.',
buttonPreview: 'Iniciar health-scan',
cta: 'Continuar',
skip: 'Mas tarde',
flow: ['Escanear planta', 'Guardar', 'Abrir detalle', 'Iniciar health-scan'],
outputTitle: 'Que recibes despues',
outputs: [
'Puntaje de salud con estado: estable, observar o critico.',
'Analisis detallado con senales visibles e incertidumbre.',
'Causas probables con valores de confianza.',
'Acciones inmediatas y plan concreto de 7 dias.',
],
guidanceNote: 'Consejo: fotografia la planta completa, el reverso de las hojas y el sustrato. Cuanto mas clara sea la foto, mas preciso sera el plan.',
};
}
return {
step: 'Step 4 of 4',
title: 'Where is the health scan?',
subtitle: 'It lives on every saved plant, directly below the plant description.',
buttonPreview: 'Start health scan',
cta: 'Continue',
skip: 'Later',
flow: ['Scan plant', 'Save', 'Open detail', 'Start health scan'],
outputTitle: 'What you get after',
outputs: [
'Health score with stable, watch, or critical status.',
'Detailed analysis with visible signals and uncertainty.',
'Most likely causes with confidence values.',
'Immediate actions plus a concrete 7-day care plan.',
],
guidanceNote: 'Tip: photograph the full plant, leaf undersides, and the soil. The clearer the photo, the more precise the plan.',
};
};
export default function HealthCheckOnboardingScreen() {
const router = useRouter();
const posthog = usePostHog();
const { isDarkMode, colorPalette, language, billingSummary } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;
const copy = getHealthOnboardingCopy(language);
const finish = (skipped = false) => {
posthog.capture('onboarding_health_check_explained', {
skipped,
plan: billingSummary?.entitlement?.plan ?? 'free',
});
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active';
router.replace(hasActiveEntitlement ? '/(tabs)' : '/profile/billing');
};
return (
<View style={[styles.container, { backgroundColor: screenBackground }]}>
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}>
<View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
</View>
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{copy.subtitle}</Text>
</View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<ImageBackground
source={require('../../assets/onboarding_health_scan_mockup.png')}
style={[styles.illustration, { borderColor: colors.border }]}
imageStyle={styles.illustrationImage}
resizeMode="cover"
>
<View style={[styles.illustrationOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.08)' : 'rgba(251, 250, 243, 0.04)' }]} />
</ImageBackground>
<View style={[styles.flowCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
{copy.flow.map((item, index) => (
<View key={item} style={styles.flowRow}>
<View style={[styles.flowIndex, { backgroundColor: index === 3 ? colors.primary : colors.surfaceMuted }]}>
<Text style={[styles.flowIndexText, { color: index === 3 ? colors.onPrimary : colors.textMuted }]}>
{index + 1}
</Text>
</View>
<Text style={[styles.flowText, { color: colors.text }]}>{item}</Text>
</View>
))}
</View>
<View style={[styles.outputCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.outputTitle, { color: colors.text }]}>{copy.outputTitle}</Text>
{copy.outputs.map((item) => (
<View key={item} style={styles.outputRow}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={[styles.outputText, { color: colors.textSecondary }]}>{item}</Text>
</View>
))}
</View>
<View style={[styles.guidanceCard, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Ionicons name="camera-outline" size={18} color={colors.primaryDark} />
<Text style={[styles.guidanceText, { color: colors.primaryDark }]}>{copy.guidanceNote}</Text>
</View>
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => finish(true)}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>{copy.skip}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colors.primary }]} onPress={() => finish(false)}>
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.cta}</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 },
header: { gap: 9, marginBottom: 18 },
stepPill: { alignSelf: 'flex-start', borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 },
stepLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.4 },
title: { fontSize: 30, lineHeight: 34, fontWeight: '900' },
subtitle: { fontSize: 14, lineHeight: 20 },
content: { gap: 14, paddingBottom: 12 },
illustration: { height: 230, borderRadius: 28, borderWidth: 1, justifyContent: 'center', overflow: 'hidden' },
illustrationImage: { borderRadius: 28 },
illustrationOverlay: { ...StyleSheet.absoluteFillObject },
phone: { width: 178, minHeight: 156, borderRadius: 26, borderWidth: 1, padding: 12, gap: 10, marginLeft: 16 },
phoneHeader: { height: 58, borderRadius: 18, justifyContent: 'flex-end', padding: 10 },
phoneTitle: { fontSize: 13, fontWeight: '800' },
phoneRows: { gap: 8 },
phoneRowLong: { height: 8, borderRadius: 999 },
phoneRowShort: { width: '66%', height: 8, borderRadius: 999 },
healthButtonPreview: { height: 34, borderRadius: 14, borderWidth: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 5 },
healthButtonText: { fontSize: 10, fontWeight: '800' },
scanCard: { position: 'absolute', right: 16, bottom: 20, width: 136, borderRadius: 20, borderWidth: 1, padding: 14, gap: 7 },
scanScore: { fontSize: 25, lineHeight: 29, fontWeight: '900' },
scanLabel: { fontSize: 11, fontWeight: '800', textTransform: 'uppercase' },
scanLine: { height: 8, borderRadius: 999 },
scanLineShort: { width: '68%', height: 8, borderRadius: 999 },
flowCard: { borderRadius: 18, borderWidth: 1, padding: 14, gap: 10 },
flowRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
flowIndex: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' },
flowIndexText: { fontSize: 12, fontWeight: '900' },
flowText: { flex: 1, fontSize: 14, fontWeight: '700' },
outputCard: { borderRadius: 18, borderWidth: 1, padding: 16, gap: 11 },
outputTitle: { fontSize: 15, fontWeight: '800' },
outputRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9 },
outputText: { flex: 1, fontSize: 13, lineHeight: 18 },
guidanceCard: { borderRadius: 18, borderWidth: 1, padding: 14, flexDirection: 'row', alignItems: 'flex-start', gap: 10 },
guidanceText: { flex: 1, fontSize: 12, lineHeight: 18, fontWeight: '600' },
footer: { flexDirection: 'row', gap: 12, marginTop: 12 },
secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
secondaryBtnText: { fontSize: 15, fontWeight: '600' },
primaryBtn: { flex: 1.3, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtnText: { fontSize: 15, fontWeight: '700' },
});

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@@ -9,21 +9,82 @@ import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService';
const ONBOARDING_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
const SOURCE_OPTIONS = [
{ id: 'app_store', icon: 'phone-portrait-outline' as const },
{ id: 'instagram', icon: 'logo-instagram' as const },
{ id: 'tiktok', icon: 'musical-notes-outline' as const },
{ id: 'friend', icon: 'people-outline' as const },
{ id: 'search', icon: 'search-outline' as const },
{ id: 'other', icon: 'ellipsis-horizontal-circle-outline' as const },
{ id: 'app_store', icon: 'storefront-outline' as const, signal: 'organic_store' },
{ id: 'instagram', icon: 'logo-instagram' as const, signal: 'social_visual' },
{ id: 'tiktok', icon: 'musical-notes-outline' as const, signal: 'social_video' },
{ id: 'friend', icon: 'people-outline' as const, signal: 'referral' },
{ id: 'search', icon: 'search-outline' as const, signal: 'high_intent_search' },
{ id: 'other', icon: 'ellipsis-horizontal-circle-outline' as const, signal: 'unclassified' },
];
const getSourceOnboardingCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
step: 'Schritt 1 von 4',
heroTitle: 'Dein Start wird danach personalisiert.',
heroMeta: 'Scan, Sammlung und Health-Check passen sich deinem Ziel an.',
valueTitle: 'Warum wir fragen',
valueBody: 'Die Antwort hilft, deinen Einstieg auf das auszurichten, was dich wirklich hierher gebracht hat.',
subtitles: {
app_store: 'Du hast aktiv nach Pflanzen- oder Pflegehilfe gesucht.',
instagram: 'Du kamst ueber visuelle Pflanzen-Inhalte.',
tiktok: 'Du kamst ueber kurze Videos oder Creator.',
friend: 'Persoenliche Empfehlung, hoher Vertrauens-Intent.',
search: 'Konkretes Problem oder schneller Pflanzen-Check.',
other: 'Passt nicht sauber in die anderen Quellen.',
},
};
}
if (language === 'es') {
return {
step: 'Paso 1 de 4',
heroTitle: 'Tu inicio se adapta despues.',
heroMeta: 'Escaneo, coleccion y health-check segun tu objetivo.',
valueTitle: 'Por que preguntamos',
valueBody: 'La respuesta ayuda a adaptar el inicio a lo que realmente te trajo aqui.',
subtitles: {
app_store: 'Buscaste ayuda para plantas o cuidado.',
instagram: 'Llegaste desde contenido visual de plantas.',
tiktok: 'Llegaste desde videos cortos o creadores.',
friend: 'Recomendacion personal con alta confianza.',
search: 'Problema concreto o chequeo rapido.',
other: 'No encaja en las demas fuentes.',
},
};
}
return {
step: 'Step 1 of 4',
heroTitle: 'Your first run adapts next.',
heroMeta: 'Scanner, collection, and health check based on your goal.',
valueTitle: 'Why we ask',
valueBody: 'This helps tailor the first steps to what actually brought you here.',
subtitles: {
app_store: 'You actively searched for plant or care help.',
instagram: 'You came from visual plant content.',
tiktok: 'You came from short videos or creators.',
friend: 'Personal referral with high trust intent.',
search: 'Concrete problem or quick plant check intent.',
other: 'Does not fit the other sources cleanly.',
},
};
};
export default function OnboardingSourceScreen() {
const router = useRouter();
const posthog = usePostHog();
const { session, isDarkMode, colorPalette, t } = useApp();
const { session, isDarkMode, colorPalette, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;
const [selectedSource, setSelectedSource] = useState<string | null>(null);
const copy = getSourceOnboardingCopy(language);
const sourceLabels = useMemo(
() => ({
@@ -51,18 +112,40 @@ export default function OnboardingSourceScreen() {
posthog.capture('onboarding_source_completed', {
source: source ?? 'skipped',
revops_signal: SOURCE_OPTIONS.find((option) => option.id === source)?.signal ?? 'skipped',
});
router.replace('/onboarding/goal');
};
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ThemeBackdrop colors={colors} />
<View style={[styles.container, { backgroundColor: screenBackground }]}>
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}>
<View style={[styles.headerIcon, { backgroundColor: colors.primarySoft }]}>
<Ionicons name="paper-plane-outline" size={26} color={colors.primaryDark} />
<View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
</View>
<ImageBackground
source={require('../../assets/onboarding_source_mockup.png')}
style={[styles.heroPreview, { borderColor: colors.border }]}
imageStyle={styles.heroImage}
resizeMode="cover"
>
<View style={[styles.heroOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.46)' : 'rgba(251, 250, 243, 0.32)' }]} />
<View style={styles.heroContent}>
<View style={[styles.heroIcon, { backgroundColor: colors.primary }]}>
<Ionicons name="scan-outline" size={20} color={colors.onPrimary} />
</View>
<View style={styles.heroCopy}>
<Text style={[styles.heroTitle, { color: isDarkMode ? colors.textOnImage : colors.text }]}>
{copy.heroTitle}
</Text>
<Text style={[styles.heroMeta, { color: isDarkMode ? '#d7ded9' : colors.textSecondary }]}>
{copy.heroMeta}
</Text>
</View>
</View>
</ImageBackground>
<Text style={[styles.title, { color: colors.text }]}>{t.sourceOnboardingTitle}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.sourceOnboardingSubtitle}</Text>
</View>
@@ -86,8 +169,13 @@ export default function OnboardingSourceScreen() {
<View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}>
<Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} />
</View>
<Text style={[styles.optionLabel, { color: colors.text }]}>{sourceLabels[option.id as keyof typeof sourceLabels]}</Text>
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} />}
<View style={styles.optionCopy}>
<Text style={[styles.optionLabel, { color: colors.text }]}>{sourceLabels[option.id as keyof typeof sourceLabels]}</Text>
<Text style={[styles.optionSubtitle, { color: colors.textMuted }]}>
{copy.subtitles[option.id as keyof typeof copy.subtitles]}
</Text>
</View>
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} style={styles.optionCheck} />}
</TouchableOpacity>
);
})}
@@ -130,66 +218,123 @@ const styles = StyleSheet.create({
safeArea: {
flex: 1,
paddingHorizontal: 20,
paddingTop: 24,
paddingBottom: 20,
paddingTop: 12,
paddingBottom: 14,
justifyContent: 'space-between',
},
header: {
alignItems: 'center',
gap: 10,
marginBottom: 28,
gap: 9,
},
headerIcon: {
width: 64,
height: 64,
borderRadius: 32,
stepPill: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 7,
},
stepLabel: {
fontSize: 12,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
heroPreview: {
width: '100%',
height: 175,
borderRadius: 24,
borderWidth: 1,
justifyContent: 'flex-end',
overflow: 'hidden',
},
heroImage: {
borderRadius: 24,
},
heroOverlay: {
...StyleSheet.absoluteFillObject,
},
heroContent: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 12,
padding: 12,
},
heroIcon: {
width: 36,
height: 36,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
heroCopy: {
flex: 1,
gap: 3,
},
heroTitle: {
fontSize: 15,
lineHeight: 18,
fontWeight: '800',
},
heroMeta: {
fontSize: 10.5,
lineHeight: 14,
fontWeight: '600',
},
title: {
fontSize: 28,
fontSize: 25,
fontWeight: '800',
textAlign: 'center',
lineHeight: 32,
lineHeight: 29,
},
subtitle: {
fontSize: 14,
fontSize: 13,
textAlign: 'center',
lineHeight: 20,
lineHeight: 18,
maxWidth: 320,
},
options: {
gap: 12,
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
optionCard: {
minHeight: 64,
borderRadius: 18,
width: '48.8%',
minHeight: 68,
borderRadius: 15,
borderWidth: 1.5,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
gap: 12,
padding: 9,
gap: 8,
position: 'relative',
},
optionIcon: {
width: 36,
height: 36,
borderRadius: 18,
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
},
optionCopy: {
gap: 3,
},
optionLabel: {
flex: 1,
fontSize: 15,
fontWeight: '600',
fontSize: 13,
fontWeight: '700',
},
optionSubtitle: {
fontSize: 10,
lineHeight: 13,
},
optionCheck: {
position: 'absolute',
right: 9,
top: 9,
},
footer: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
secondaryBtn: {
flex: 1,
height: 52,
height: 50,
borderRadius: 16,
borderWidth: 1.5,
alignItems: 'center',
@@ -201,7 +346,7 @@ const styles = StyleSheet.create({
},
primaryBtn: {
flex: 1.2,
height: 52,
height: 50,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',

View File

@@ -57,20 +57,23 @@ const HEALTH_CHECK_CREDIT_COST = 2;
const getHealthCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
title: 'Health Check',
action: 'Neues Foto + Health-Check',
running: 'Neues Foto wird analysiert...',
cost: `Kosten: ${HEALTH_CHECK_CREDIT_COST} Credits`,
creditsLabel: 'Credits',
title: 'Health Check',
action: 'Health-Scan starten',
running: 'Neues Foto wird analysiert...',
cost: `Kosten: ${HEALTH_CHECK_CREDIT_COST} Credits`,
intro: 'Fotografiere die ganze Pflanze plus auffaellige Blaetter. Danach bekommst du Diagnose, Dringlichkeit und einen konkreten Pflegeplan.',
creditsLabel: 'Credits',
managePlan: 'Plan verwalten',
noCreditsTitle: 'Nicht genug Credits',
noCreditsMessage: `Du brauchst ${HEALTH_CHECK_CREDIT_COST} Credits fuer den Health-Check.`,
insufficientInline: 'Nicht genug Credits fuer den Health-Check.',
timeoutInline: 'Health-Check Timeout. Bitte erneut versuchen.',
providerInline: 'Health-Check ist gerade nicht verfuegbar.',
issuesTitle: 'Moegliche Ursachen',
actionsTitle: 'Sofortmassnahmen',
planTitle: '7-Tage-Plan',
analysisTitle: 'Analyse',
analysisFallback: 'Die Pflanze wirkt insgesamt beurteilbar, aber die gespeicherte Analyse enthaelt noch keine ausformulierte Zusammenfassung. Orientiere dich deshalb an Score, Ursachen und Sofortmassnahmen. Pruefe zuerst die auffaelligsten Blaetter, danach Substratfeuchte und Standort. Wenn die Blaetter innerhalb von 48 Stunden weiter haengen, gelb werden oder Flecken ausbreiten, solltest du ein neues Foto bei hellem indirektem Licht aufnehmen. Ein neuer Health-Scan kann dann genauer zwischen Wasserstress, Lichtstress, Schaedlingen und normaler Blattalterung unterscheiden.',
issuesTitle: 'Wahrscheinlichste Ursachen',
actionsTitle: 'Sofortmassnahmen',
planTitle: '7-Tage-Plan',
scoreLabel: 'Gesundheits-Score',
healthy: 'Stabil',
watch: 'Beobachten',
@@ -81,18 +84,21 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'es') {
return {
title: 'Health Check',
action: 'Foto nuevo + Health-check',
running: 'Analizando foto nueva...',
cost: `Costo: ${HEALTH_CHECK_CREDIT_COST} creditos`,
creditsLabel: 'Creditos',
title: 'Health Check',
action: 'Iniciar health-scan',
running: 'Analizando foto nueva...',
cost: `Costo: ${HEALTH_CHECK_CREDIT_COST} creditos`,
intro: 'Fotografia la planta completa y las hojas llamativas. Luego recibes diagnostico, urgencia y un plan de cuidado concreto.',
creditsLabel: 'Creditos',
managePlan: 'Gestionar plan',
noCreditsTitle: 'Creditos insuficientes',
noCreditsMessage: `Necesitas ${HEALTH_CHECK_CREDIT_COST} creditos para el health-check.`,
insufficientInline: 'No hay creditos suficientes para el health-check.',
timeoutInline: 'Health-check agotado por tiempo. Intenta de nuevo.',
providerInline: 'Health-check no disponible ahora.',
issuesTitle: 'Posibles causas',
analysisTitle: 'Analisis',
analysisFallback: 'La planta se puede evaluar en general, pero este chequeo guardado todavia no contiene un resumen completo. Usa el puntaje, las causas y las acciones inmediatas como guia principal. Revisa primero las hojas mas llamativas, despues la humedad del sustrato y la ubicacion. Si las hojas empeoran en 48 horas, amarillean o las manchas se expanden, toma una foto nueva con luz indirecta clara. Un nuevo health-scan podra diferenciar mejor entre exceso o falta de agua, luz, plagas y envejecimiento normal.',
issuesTitle: 'Causas mas probables',
actionsTitle: 'Acciones inmediatas',
planTitle: 'Plan de 7 dias',
scoreLabel: 'Puntaje de salud',
@@ -104,10 +110,11 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
}
return {
title: 'Health Check',
action: 'New Photo + Health Check',
running: 'Analyzing new photo...',
cost: `Cost: ${HEALTH_CHECK_CREDIT_COST} credits`,
title: 'Health Check',
action: 'Start health scan',
running: 'Analyzing new photo...',
cost: `Cost: ${HEALTH_CHECK_CREDIT_COST} credits`,
intro: 'Photograph the full plant plus any suspicious leaves. You will get a diagnosis, urgency level, and a concrete care plan.',
creditsLabel: 'Credits',
managePlan: 'Manage plan',
noCreditsTitle: 'Not enough credits',
@@ -115,7 +122,9 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
insufficientInline: 'Not enough credits for the health check.',
timeoutInline: 'Health check timed out. Please try again.',
providerInline: 'Health check is unavailable right now.',
issuesTitle: 'Likely issues',
analysisTitle: 'Analysis',
analysisFallback: 'The plant is still assessable, but this saved check does not include a full written summary yet. Use the score, likely causes, and immediate actions as the primary guide. Start by inspecting the most unusual leaves, then check soil moisture and placement. If leaves droop further, yellowing spreads, or spots expand within 48 hours, take a new photo in bright indirect light. A fresh health scan can separate watering stress, light stress, pests, and normal leaf aging more accurately.',
issuesTitle: 'Most likely causes',
actionsTitle: 'Actions now',
planTitle: '7-day plan',
scoreLabel: 'Health score',
@@ -234,7 +243,7 @@ export default function PlantDetailScreen() {
: colors.dangerSoft
)
: colors.surfaceMuted;
const latestStatusColor = latestHealthCheck
const latestStatusColor = latestHealthCheck
? (
latestHealthCheck.status === 'healthy'
? colors.success
@@ -242,7 +251,10 @@ export default function PlantDetailScreen() {
? colors.warning
: colors.danger
)
: colors.textMuted;
: colors.textMuted;
const latestAnalysisSummary = latestHealthCheck
? latestHealthCheck.analysisSummary || healthCopy.analysisFallback
: '';
const timelineEntries = useMemo(() => {
const history = plant.wateringHistory && plant.wateringHistory.length > 0
@@ -576,9 +588,9 @@ export default function PlantDetailScreen() {
<View style={[styles.healthActionCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<View style={styles.healthActionRow}>
<View style={styles.healthActionInfo}>
<Text style={[styles.healthActionTitle, { color: textOnSurface }]}>{healthCopy.title}</Text>
<Text style={[styles.healthActionMeta, { color: colors.textMuted }]}>{healthCopy.cost}</Text>
</View>
<Text style={[styles.healthActionTitle, { color: textOnSurface }]}>{healthCopy.title}</Text>
<Text style={[styles.healthActionMeta, { color: colors.textMuted }]}>{healthCopy.intro}</Text>
</View>
<TouchableOpacity
style={[
styles.healthActionBtn,
@@ -638,11 +650,20 @@ export default function PlantDetailScreen() {
</View>
</View>
<Text style={[styles.healthTimestamp, { color: colors.textMuted }]}>
{healthCopy.lastCheck}: {new Date(latestHealthCheck.generatedAt).toLocaleString(locale)}
</Text>
<View style={styles.healthListBlock}>
<Text style={[styles.healthTimestamp, { color: colors.textMuted }]}>
{healthCopy.lastCheck}: {new Date(latestHealthCheck.generatedAt).toLocaleString(locale)}
</Text>
{latestAnalysisSummary ? (
<View style={[styles.healthAnalysisBox, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}>
<Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.analysisTitle}</Text>
<Text style={[styles.healthAnalysisText, { color: colors.textSecondary }]}>
{latestAnalysisSummary}
</Text>
</View>
) : null}
<View style={styles.healthListBlock}>
<Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.issuesTitle}</Text>
{latestHealthCheck.likelyIssues.map((issue, index) => (
<View key={`${issue.title}-${index}`} style={styles.healthIssueWrap}>
@@ -1110,10 +1131,20 @@ const styles = StyleSheet.create({
healthTimestamp: {
fontSize: 11,
},
healthListBlock: {
gap: 8,
},
healthListTitle: {
healthListBlock: {
gap: 8,
},
healthAnalysisBox: {
borderRadius: 16,
borderWidth: 1,
padding: 12,
gap: 6,
},
healthAnalysisText: {
fontSize: 12,
lineHeight: 19,
},
healthListTitle: {
fontSize: 13,
fontWeight: '700',
},

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,9 @@ 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';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types';
import { AuthService } from '../../services/authService';
const getDataCopy = (language: Language) => {
if (language === 'de') {
@@ -118,15 +119,19 @@ export default function DataScreen() {
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 and cancel active app subscriptions
await signOut();
router.replace('/onboarding');
},
},
]);
text: copy.deleteActionBtn,
style: 'destructive',
onPress: async () => {
try {
await AuthService.deleteAccount();
await signOut();
router.replace('/onboarding');
} catch {
Alert.alert(copy.genericErrorTitle, copy.genericErrorMessage);
}
},
},
]);
};
return (

File diff suppressed because it is too large Load Diff