Files
Greenlens/app/onboarding/source.tsx
2026-07-30 10:03:37 +02:00

1022 lines
35 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
Easing,
FlatList,
Image,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions,
} 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 { useSafeAnalytics } from '../../services/analytics';
import { OnboardingProgressService } from '../../services/onboardingProgressService';
import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService';
import { Language } from '../../types';
// Avatar path matching copied file
const BOT_AVATAR = require('../../assets/robot_monstera_avatar.jpg');
interface Message {
id: string;
sender: 'bot' | 'user';
text: string;
subtext?: string;
timestamp: string;
}
const getChatCopy = (language: Language) => {
if (language === 'de') {
return {
title: 'GreenLens',
skip: 'Überspringen',
continue: 'Weiter',
today: 'Heute, 20:42 Uhr',
typing: 'schreibt...',
// Reihenfolge folgt dem Nutzer, nicht dem Unternehmen: erst sein Problem,
// dann Kontext, zuletzt die Marketing-Frage.
botGreeting: 'Hi! Ich helfe dir herauszufinden, was mit deinen Pflanzen los ist. 🌿',
botSituation: 'Damit ich weiß, wo wir starten: Hat gerade eine deiner Pflanzen ein Problem?',
botSymptom: 'Verstanden. Was siehst du genau? Das grenzt die möglichen Ursachen am stärksten ein.',
botNoSymptom: 'Gut zu wissen - dann richten wir alles auf Vorbeugung aus.',
botLight: 'Licht entscheidet bei den meisten Problemen mit. Wie viel Licht bekommt deine Pflanze?',
botExperience: 'Fast geschafft. Wie sicher fühlst du dich bei Pflanzenpflege? Danach passe ich an, wie ausführlich ich erkläre.',
botSource: 'Letzte Frage, rein aus Neugier: Wie hast du von GreenLens gehört?',
botComplete: 'Perfekt. Ich stelle dir jetzt deinen Plan zusammen.',
stepSituation: 'Hat eine Pflanze gerade ein Problem?',
stepSymptom: 'Was siehst du?',
stepLight: 'Wie viel Licht bekommt sie?',
stepExperience: 'Wie sicher fühlst du dich?',
stepSource: 'Wie hast du von uns gehört?',
situations: {
acute: 'Ja - und es wird schnell schlechter',
slow: 'Ja - es verändert sich langsam',
prevention: 'Nein - ich will vorbeugen',
},
symptoms: {
yellow: 'Gelbe Blätter',
brown_tips: 'Braune Spitzen oder Ränder',
drooping: 'Hängt oder lässt die Blätter fallen',
spots: 'Flecken oder Belag',
pests: 'Krabbeltiere',
other: 'Etwas anderes',
},
sources: {
app_store: 'App Store / Play Store',
instagram: 'Instagram',
tiktok: 'TikTok',
friend: 'Freunde oder Familie',
search: 'Google oder Suche',
// Bewusst unwahrscheinliche Optionen: sie brechen das Muster der
// Standard-Attributionsfrage und machen den letzten Schritt leichter.
tv: 'Im Fernsehen (haben wir nicht - aber schön wärs)',
radio: 'Im Radio zwischen zwei Songs',
plant: 'Meine Pflanze hat es mir empfohlen',
other: 'Etwas anderes',
},
experiences: {
beginner: 'Unsicher - ich fange gerade an',
intermediate: 'Geht so - ich habe ein paar Pflanzen',
advanced: 'Sicher - ich kenne mich aus',
},
lights: {
bright_indirect: {
title: 'Helles, indirektes Licht',
sub: 'In der Nähe eines sonnigen Fensters, ohne direkte Strahlen',
},
low_light: {
title: 'Wenig Licht',
sub: 'Weit weg von Fenstern, meistens Schatten',
},
direct_sunlight: {
title: 'Direktes Sonnenlicht',
sub: 'Den ganzen Tag direkt an einem sonnigen Fenster',
},
},
};
}
if (language === 'es') {
return {
title: 'GreenLens',
skip: 'Omitir',
continue: 'Continuar',
today: 'Hoy, 8:42 PM',
typing: 'escribiendo...',
botGreeting: '¡Hola! Te ayudo a averiguar qué les pasa a tus plantas. 🌿',
botSituation: 'Para saber por dónde empezamos: ¿alguna de tus plantas tiene un problema ahora mismo?',
botSymptom: 'Entendido. ¿Qué ves exactamente? Esto es lo que más acota las causas posibles.',
botNoSymptom: 'Bien saberlo - entonces lo enfocamos todo en la prevención.',
botLight: 'La luz influye en la mayoría de los problemas. ¿Cuánta luz recibe tu planta?',
botExperience: 'Casi listo. ¿Qué tan seguro te sientes cuidando plantas? Con eso ajusto cuánto explico.',
botSource: 'Última pregunta, por curiosidad: ¿cómo te enteraste de GreenLens?',
botComplete: 'Perfecto. Ahora preparo tu plan.',
stepSituation: '¿Alguna planta tiene un problema?',
stepSymptom: '¿Qué ves?',
stepLight: '¿Cuánta luz recibe?',
stepExperience: '¿Qué tan seguro te sientes?',
stepSource: '¿Cómo nos conociste?',
situations: {
acute: 'Sí - y empeora rápido',
slow: 'Sí - cambia poco a poco',
prevention: 'No - quiero prevenir',
},
symptoms: {
yellow: 'Hojas amarillas',
brown_tips: 'Puntas o bordes marrones',
drooping: 'Se cae o pierde hojas',
spots: 'Manchas o residuo',
pests: 'Bichos',
other: 'Otra cosa',
},
sources: {
app_store: 'App Store / Play Store',
instagram: 'Instagram',
tiktok: 'TikTok',
friend: 'Amigos o familia',
search: 'Google o búsqueda',
tv: 'En la tele (no tenemos anuncios - pero ojalá)',
radio: 'En la radio entre dos canciones',
plant: 'Me la recomendó mi planta',
other: 'Otra cosa',
},
experiences: {
beginner: 'Inseguro - recién empiezo',
intermediate: 'Más o menos - tengo algunas plantas',
advanced: 'Seguro - sé bastante',
},
lights: {
bright_indirect: {
title: 'Luz brillante e indirecta',
sub: 'Cerca de una ventana soleada, sin sol directo',
},
low_light: {
title: 'Poca luz',
sub: 'Lejos de las ventanas, principalmente sombra',
},
direct_sunlight: {
title: 'Luz solar directa',
sub: 'Directamente en una ventana soleada todo el día',
},
},
};
}
return {
title: 'GreenLens',
skip: 'Skip',
continue: 'Continue',
today: 'Today, 8:42 PM',
typing: 'typing...',
botGreeting: "Hi! I'll help you work out what's going on with your plants. 🌿",
botSituation: 'So I know where to start: is one of your plants having a problem right now?',
botSymptom: 'Got it. What exactly do you see? This narrows down the possible causes more than anything else.',
botNoSymptom: "Good to know - then we'll focus everything on prevention.",
botLight: 'Light plays a role in most problems. How much light does your plant get?',
botExperience: 'Almost done. How confident do you feel about plant care? I use that to adjust how much I explain.',
botSource: 'Last question, purely out of curiosity: how did you hear about GreenLens?',
botComplete: "Perfect. I'm putting your plan together now.",
stepSituation: 'Is a plant having a problem?',
stepSymptom: 'What do you see?',
stepLight: 'How much light does it get?',
stepExperience: 'How confident do you feel?',
stepSource: 'How did you hear about us?',
situations: {
acute: "Yes - and it's getting worse fast",
slow: "Yes - it's changing slowly",
prevention: 'No - I want to prevent problems',
},
symptoms: {
yellow: 'Yellow leaves',
brown_tips: 'Brown tips or edges',
drooping: 'Drooping or dropping leaves',
spots: 'Spots or residue',
pests: 'Bugs',
other: 'Something else',
},
sources: {
app_store: 'App Store / Play Store',
instagram: 'Instagram',
tiktok: 'TikTok',
friend: 'Friends or family',
search: 'Google or search',
tv: "On TV (we don't run ads - but a girl can dream)",
radio: 'On the radio between two songs',
plant: 'My plant recommended it',
other: 'Something else',
},
experiences: {
beginner: "Not confident - I'm just starting",
intermediate: 'So-so - I have a few plants',
advanced: 'Confident - I know my way around',
},
lights: {
bright_indirect: {
title: 'Bright, indirect light',
sub: 'Near a sunny window, but no harsh rays',
},
low_light: {
title: 'Low light',
sub: 'Far from windows, mostly shadow',
},
direct_sunlight: {
title: 'Direct sunlight',
sub: 'Directly in a sunny window all day',
},
},
};
};
/** Fragenreihenfolge des Chats. Bewusst: Problem zuerst, Attribution zuletzt. */
const STEP_KEYS = ['situation', 'symptom', 'lightLevel', 'experienceLevel', 'acquisitionSource'] as const;
type StepKey = (typeof STEP_KEYS)[number];
const TOTAL_STEPS = STEP_KEYS.length;
const TypingIndicator = ({ colors }: { colors: any }) => {
const dot1 = useRef(new Animated.Value(0)).current;
const dot2 = useRef(new Animated.Value(0)).current;
const dot3 = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animateDot = (value: Animated.Value, delay: number) => {
return Animated.loop(
Animated.sequence([
Animated.delay(delay),
Animated.timing(value, {
toValue: -6,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(value, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.delay(350),
])
);
};
const anim1 = animateDot(dot1, 0);
const anim2 = animateDot(dot2, 120);
const anim3 = animateDot(dot3, 240);
anim1.start();
anim2.start();
anim3.start();
return () => {
anim1.stop();
anim2.stop();
anim3.stop();
};
}, [dot1, dot2, dot3]);
return (
<View style={styles.typingContainer}>
<Animated.View style={[styles.typingDot, { backgroundColor: colors.primary, transform: [{ translateY: dot1 }] }]} />
<Animated.View style={[styles.typingDot, { backgroundColor: colors.primary, transform: [{ translateY: dot2 }] }]} />
<Animated.View style={[styles.typingDot, { backgroundColor: colors.primary, transform: [{ translateY: dot3 }] }]} />
</View>
);
};
export default function OnboardingChatScreen() {
const router = useRouter();
const posthog = useSafeAnalytics();
const { session, colorPalette, language } = useApp();
// This screen is hardcoded dark (#131e14 backgrounds), so always use the
// dark color set - theme-dependent text would be unreadable in light mode.
const colors = useColors(true, colorPalette);
const copy = getChatCopy(language);
const { height } = useWindowDimensions();
const compact = height < 700;
const [currentStep, setCurrentStep] = useState<number>(0);
const [messages, setMessages] = useState<Message[]>([]);
const [isTyping, setIsTyping] = useState<boolean>(false);
const [showOptions, setShowOptions] = useState<boolean>(false);
const [answers, setAnswers] = useState({
situation: null as string | null,
symptom: null as string | null,
lightLevel: null as string | null,
experienceLevel: null as string | null,
acquisitionSource: null as string | null,
});
const flatListRef = useRef<FlatList>(null);
const inputScrollRef = useRef<ScrollView>(null);
const floatAnim = useRef(new Animated.Value(0)).current;
// Float animation loop for the background monstera pot
useEffect(() => {
const float = () => {
Animated.sequence([
Animated.timing(floatAnim, {
toValue: -12,
duration: 3000,
easing: Easing.inOut(Easing.sin),
useNativeDriver: true,
}),
Animated.timing(floatAnim, {
toValue: 0,
duration: 3000,
easing: Easing.inOut(Easing.sin),
useNativeDriver: true,
}),
]).start(() => float());
};
float();
}, [floatAnim]);
// Initial Bot Greeting
useEffect(() => {
setIsTyping(true);
setShowOptions(false);
const timer1 = setTimeout(() => {
setMessages([
{
id: 'greeting',
sender: 'bot',
text: copy.botGreeting,
timestamp: copy.today,
},
]);
setIsTyping(false);
// Delay before asking first question
const timer2 = setTimeout(() => {
setIsTyping(true);
const timer3 = setTimeout(() => {
setMessages((prev) => [
...prev,
{
id: 'question-situation',
sender: 'bot',
text: copy.botSituation,
timestamp: copy.today,
},
]);
setIsTyping(false);
setShowOptions(true); // Only show options after question is fully posted!
}, 800);
return () => clearTimeout(timer3);
}, 500);
return () => clearTimeout(timer2);
}, 1200);
return () => clearTimeout(timer1);
}, []);
// Scroll to bottom on messages update
useEffect(() => {
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true });
}, 100);
}, [messages, isTyping]);
useEffect(() => {
inputScrollRef.current?.scrollTo({ y: 0, animated: false });
}, [currentStep]);
const selectOption = (id: string, label: string) => {
// Add user bubble
const userMsg: Message = {
id: `user-${currentStep}-${Date.now()}`,
sender: 'user',
text: label,
timestamp: copy.today,
};
setMessages((prev) => [...prev, userMsg]);
// Wer "vorbeugen" waehlt, hat kein Symptom - die Symptomfrage wird
// uebersprungen. Ohne das antwortet der Bot "dann beugen wir vor" und
// fragt im selben Atemzug "Was siehst du?".
const skipsSymptom = currentStep === 0 && id === 'prevention';
const nextStep = skipsSymptom ? 2 : currentStep + 1;
setCurrentStep(nextStep);
// Save answer
const nextAnswers = { ...answers };
const stepKey: StepKey | undefined = STEP_KEYS[currentStep];
if (stepKey) nextAnswers[stepKey] = id;
setAnswers(nextAnswers);
// Trigger bot response typing
setIsTyping(true);
setShowOptions(false); // Hide immediately when user answers
const delay = setTimeout(() => {
let botText = '';
let botSubtext = undefined;
if (nextStep === 1) {
botText = copy.botSymptom;
} else if (nextStep === 2) {
// Nach dem uebersprungenen Symptomschritt zuerst die Ueberleitung,
// damit die Antwort des Nutzers nicht unkommentiert bleibt.
botText = skipsSymptom ? `${copy.botNoSymptom} ${copy.botLight}` : copy.botLight;
} else if (nextStep === 3) {
botText = copy.botExperience;
} else if (nextStep === 4) {
botText = copy.botSource;
} else if (nextStep === TOTAL_STEPS) {
botText = copy.botComplete;
}
setMessages((prev) => [
...prev,
{
id: `bot-${nextStep}-${Date.now()}`,
sender: 'bot',
text: botText,
subtext: botSubtext,
timestamp: copy.today,
},
]);
setIsTyping(false);
setShowOptions(true); // Re-enable options after bot finishes typing
}, 850);
return () => clearTimeout(delay);
};
const handleBack = () => {
if (currentStep === 0) {
router.back();
return;
}
// Gegenstueck zum uebersprungenen Symptomschritt: von Schritt 2 zurueck
// landet ein Vorbeuger wieder bei der Situationsfrage, nicht bei einer
// Symptomfrage, die er nie gesehen hat.
const skippedSymptom = currentStep === 2 && answers.situation === 'prevention';
const prevStep = skippedSymptom ? 0 : currentStep - 1;
setCurrentStep(prevStep);
// Revert last answers
const nextAnswers = { ...answers };
const prevKey: StepKey | undefined = STEP_KEYS[prevStep];
if (prevKey) nextAnswers[prevKey] = null;
setAnswers(nextAnswers);
// Remove user bubble and bot bubble from messages history
setMessages((prev) => {
const copyMsgs = [...prev];
// Pop bot question bubble and user response bubble
copyMsgs.pop();
copyMsgs.pop();
return copyMsgs;
});
};
const onSkip = () => {
posthog.capture('onboarding_chat_skipped', { step: currentStep });
router.replace('/onboarding/health-check');
};
const onFinish = () => {
if (session?.userId) {
if (answers.acquisitionSource) OnboardingProgressService.setAcquisitionSource(session.userId, answers.acquisitionSource);
if (answers.experienceLevel) OnboardingProgressService.setExperienceLevel(session.userId, answers.experienceLevel);
}
if (answers.acquisitionSource) void PreAuthOnboardingService.setAnswer('acquisitionSource', answers.acquisitionSource);
if (answers.experienceLevel) void PreAuthOnboardingService.setAnswer('experienceLevel', answers.experienceLevel);
if (answers.lightLevel) void PreAuthOnboardingService.setAnswer('lightLevel', answers.lightLevel);
// Situation und Symptom steuern spaeter die Personalisierung des Plans.
if (answers.situation) void PreAuthOnboardingService.setAnswer('situation', answers.situation);
if (answers.symptom) void PreAuthOnboardingService.setAnswer('symptom', answers.symptom);
posthog.capture('onboarding_chat_completed', {
situation: answers.situation,
symptom: answers.symptom,
light: answers.lightLevel,
experience: answers.experienceLevel,
source: answers.acquisitionSource,
});
router.replace('/onboarding/health-check');
};
// Build options list based on current question step
const renderOptions = () => {
if (!showOptions || isTyping) return null;
// Schritt 1 - Situation. Erste Frage gehoert dem Nutzer, nicht dem Marketing.
if (currentStep === 0) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle} maxFontSizeMultiplier={1.3}>{copy.stepSituation}</Text>
<View style={styles.optionsVertical}>
{([
{ id: 'acute', label: copy.situations.acute, icon: 'alert-circle-outline' as const },
{ id: 'slow', label: copy.situations.slow, icon: 'trending-down-outline' as const },
{ id: 'prevention', label: copy.situations.prevention, icon: 'shield-checkmark-outline' as const },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.optionRow, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<View style={styles.optionRowLeft}>
<Ionicons name={opt.icon} size={18} color={colors.primary} style={{ marginRight: 10 }} />
<Text style={[styles.rowText, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{opt.label}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</TouchableOpacity>
))}
</View>
</View>
);
}
// Schritt 2 - Symptom. Der Moment, in dem sich der Nutzer wiedererkennt.
if (currentStep === 1) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle} maxFontSizeMultiplier={1.3}>{copy.stepSymptom}</Text>
<View style={styles.optionsGrid}>
{([
{ id: 'yellow', label: copy.symptoms.yellow, icon: 'leaf-outline' as const },
{ id: 'brown_tips', label: copy.symptoms.brown_tips, icon: 'flame-outline' as const },
{ id: 'drooping', label: copy.symptoms.drooping, icon: 'trending-down-outline' as const },
{ id: 'spots', label: copy.symptoms.spots, icon: 'ellipse-outline' as const },
{ id: 'pests', label: copy.symptoms.pests, icon: 'bug-outline' as const },
{ id: 'other', label: copy.symptoms.other, icon: 'help-circle-outline' as const },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.pillBtn, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<Ionicons name={opt.icon} size={15} color={colors.primary} style={{ marginRight: 4 }} />
<Text style={[styles.pillLabel, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{opt.label}</Text>
</TouchableOpacity>
))}
</View>
</View>
);
}
// Schritt 3 - Licht.
if (currentStep === 2) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle} maxFontSizeMultiplier={1.3}>{copy.stepLight}</Text>
<View style={styles.optionsVertical}>
{([
{ id: 'bright_indirect', label: copy.lights.bright_indirect.title, sub: copy.lights.bright_indirect.sub, icon: 'wb-twilight' },
{ id: 'low_light', label: copy.lights.low_light.title, sub: copy.lights.low_light.sub, icon: 'brightness-4' },
{ id: 'direct_sunlight', label: copy.lights.direct_sunlight.title, sub: copy.lights.direct_sunlight.sub, icon: 'sunny' },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.bentoOption, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<View style={styles.bentoCopy}>
<Text style={[styles.bentoTitle, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{opt.label}</Text>
<Text style={[styles.bentoSub, { color: colors.textSecondary }]} maxFontSizeMultiplier={1.3}>{opt.sub}</Text>
</View>
<Ionicons name={opt.icon === 'sunny' ? 'sunny-outline' : opt.icon === 'brightness-4' ? 'contrast' : 'partly-sunny-outline'} size={20} color={colors.primary} />
</TouchableOpacity>
))}
</View>
</View>
);
}
// Schritt 4 - Erfahrung. Formuliert als Sicherheitsgefuehl, nicht als
// Wissenstest: Emma soll sich nicht schon hier als Anfaengerin abstempeln.
if (currentStep === 3) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle} maxFontSizeMultiplier={1.3}>{copy.stepExperience}</Text>
<View style={styles.optionsVertical}>
{([
{ id: 'beginner', label: copy.experiences.beginner, icon: 'leaf-outline' as const },
{ id: 'intermediate', label: copy.experiences.intermediate, icon: 'sunny-outline' as const },
{ id: 'advanced', label: copy.experiences.advanced, icon: 'flask-outline' as const },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.optionRow, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<View style={styles.optionRowLeft}>
<Ionicons name={opt.icon} size={18} color={colors.primary} style={{ marginRight: 10 }} />
<Text style={[styles.rowText, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{opt.label}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</TouchableOpacity>
))}
</View>
</View>
);
}
// Schritt 5 - Attribution. Ganz ans Ende verschoben: die Frage nuetzt uns,
// nicht dem Nutzer. Die unwahrscheinlichen Optionen (TV, Radio, "meine
// Pflanze") brechen das Muster und machen den letzten Tap leicht.
if (currentStep === 4) {
return (
<View style={styles.optionsWrap}>
<Text style={styles.stepTitle} maxFontSizeMultiplier={1.3}>{copy.stepSource}</Text>
<View style={styles.optionsGrid}>
{([
{ id: 'app_store', label: copy.sources.app_store, icon: 'logo-apple' as const },
{ id: 'instagram', label: copy.sources.instagram, icon: 'logo-instagram' as const },
{ id: 'tiktok', label: copy.sources.tiktok, icon: 'musical-notes-outline' as const },
{ id: 'friend', label: copy.sources.friend, icon: 'people-outline' as const },
{ id: 'search', label: copy.sources.search, icon: 'search-outline' as const },
{ id: 'tv', label: copy.sources.tv, icon: 'tv-outline' as const },
{ id: 'radio', label: copy.sources.radio, icon: 'radio-outline' as const },
{ id: 'plant', label: copy.sources.plant, icon: 'leaf-outline' as const },
{ id: 'other', label: copy.sources.other, icon: 'sparkles-outline' as const },
]).map((opt) => (
<TouchableOpacity key={opt.id} style={[styles.pillBtn, { borderColor: colors.border }]} onPress={() => selectOption(opt.id, opt.label)}>
<Ionicons name={opt.icon} size={15} color={colors.primary} style={{ marginRight: 4 }} />
<Text style={[styles.pillLabel, { color: colors.text }]} maxFontSizeMultiplier={1.3}>{opt.label}</Text>
</TouchableOpacity>
))}
</View>
</View>
);
}
// Alle Fragen beantwortet -> Abschluss-CTA.
if (currentStep >= TOTAL_STEPS) {
return (
<View style={styles.footerWrap}>
<TouchableOpacity style={[styles.continueCta, { backgroundColor: colors.primary }]} onPress={onFinish} activeOpacity={0.86}>
<Text style={[styles.continueCtaText, { color: colors.onPrimary }]} maxFontSizeMultiplier={1.2}>{copy.continue}</Text>
</TouchableOpacity>
</View>
);
}
return null;
};
const renderMessageItem = ({ item }: { item: Message }) => {
const isBot = item.sender === 'bot';
return (
<View style={[styles.messageRow, isBot ? styles.messageRowLeft : styles.messageRowRight]}>
{isBot && (
<Image source={BOT_AVATAR} style={styles.chatAvatar} resizeMode="cover" />
)}
<View style={[
styles.bubble,
isBot
? [styles.bubbleLeft, { backgroundColor: '#142215' }]
: [styles.bubbleRight, { backgroundColor: '#2e4f00' }]
]}>
<Text style={[styles.bubbleText, { color: isBot ? '#dae6d6' : '#ffffff' }]}>{item.text}</Text>
{item.subtext ? (
<Text style={styles.bubbleSub}>{item.subtext}</Text>
) : null}
</View>
</View>
);
};
const progressPercent = `${Math.min(Math.round((currentStep / TOTAL_STEPS) * 100), 100)}%`;
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Animated.Image
source={{ uri: 'https://lh3.googleusercontent.com/aida/AP1WRLvNv80S62DEz_zBQ-9HwoEIRjqH2RBaY0knbsg2dBsQySI1kJC23xC8q_rkQ_100IiLnW4uwtzj0t7koHQvFUc6-4kyGCdyYdE7JY_nMeC628hnY7KYw52GNy1ErcjFii-YOISxq1GRLNpaFN_IklHttmJ30NC85GX4pCt7Q23hrYSQ_Mfx4_T1lqXum2KdLfiyyOZoRKuLCP0jTBbAcEi8DCLZtsAUSYcttqmUtuaTMnt5W07_sIn0dAw' }}
style={[styles.robotBackground, { transform: [{ translateY: floatAnim }] }]}
resizeMode="cover"
/>
{/* Soft stepwise gradient mask to blend bottom image edge with background */}
<View style={styles.maskContainer}>
<View style={[styles.maskLine, { opacity: 0.1, backgroundColor: '#131e14' }]} />
<View style={[styles.maskLine, { opacity: 0.25, backgroundColor: '#131e14' }]} />
<View style={[styles.maskLine, { opacity: 0.45, backgroundColor: '#131e14' }]} />
<View style={[styles.maskLine, { opacity: 0.65, backgroundColor: '#131e14' }]} />
<View style={[styles.maskLine, { opacity: 0.85, backgroundColor: '#131e14' }]} />
<View style={[styles.maskLine, { opacity: 1.0, backgroundColor: '#131e14' }]} />
</View>
<SafeAreaView style={styles.safe} edges={['top', 'bottom']}>
{/* Header Bar */}
<View style={styles.header}>
<TouchableOpacity onPress={handleBack} style={styles.headerBack}>
<Ionicons name="arrow-back" size={24} color="#dae6d6" />
</TouchableOpacity>
<View style={styles.progressContainer}>
<View style={styles.progressBarTrack}>
<View style={[styles.progressBarFill, { width: progressPercent as any }]} />
</View>
</View>
<TouchableOpacity onPress={onSkip} style={styles.headerSkip}>
<Text style={styles.skipText}>{copy.skip}</Text>
</TouchableOpacity>
</View>
{/* Chat History Panel */}
<View style={styles.chatPanel}>
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.id}
renderItem={renderMessageItem}
contentContainerStyle={styles.chatListContent}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View style={styles.timeDivider}>
<Text style={styles.timeDividerText}>{copy.today}</Text>
</View>
}
ListFooterComponent={
isTyping ? (
<View style={[styles.messageRow, styles.messageRowLeft]}>
<Image source={BOT_AVATAR} style={styles.chatAvatar} resizeMode="cover" />
<View style={[styles.bubble, styles.bubbleLeft, { backgroundColor: '#142215' }]}>
<TypingIndicator colors={colors} />
</View>
</View>
) : null
}
/>
</View>
{/* Input/Selection Panel. Scrollbar und in der Hoehe gedeckelt: der
Attributions-Schritt hat 9 Optionen, die bei grosser Systemschrift
sonst den Chatverlauf und den Weiter-Button verdraengen. */}
<View style={styles.inputPanel}>
<ScrollView
ref={inputScrollRef}
contentContainerStyle={styles.inputPanelContent}
showsVerticalScrollIndicator={false}
bounces={false}
>
{renderOptions()}
</ScrollView>
</View>
</SafeAreaView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#131e14',
},
robotBackground: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: 380,
opacity: 0.28,
},
maskContainer: {
position: 'absolute',
top: 260,
left: 0,
right: 0,
height: 120,
},
maskLine: {
height: 20,
width: '100%',
},
optionRowLeft: {
flexDirection: 'row',
alignItems: 'center',
},
safe: {
flex: 1,
justifyContent: 'space-between',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(255,255,255,0.08)',
},
headerBack: {
width: 38,
height: 38,
borderRadius: 19,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.04)',
},
progressContainer: {
flex: 1,
marginHorizontal: 16,
},
progressBarTrack: {
height: 6,
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.08)',
overflow: 'hidden',
},
progressBarFill: {
height: 6,
borderRadius: 3,
backgroundColor: '#a5d56e',
},
headerSkip: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: 'rgba(255,255,255,0.04)',
},
skipText: {
color: '#dae6d6',
fontSize: 13,
fontWeight: '700',
},
chatPanel: {
flex: 1,
paddingHorizontal: 16,
},
chatListContent: {
paddingVertical: 16,
gap: 16,
},
timeDivider: {
alignSelf: 'center',
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 4,
marginBottom: 16,
},
timeDividerText: {
color: '#8d9382',
fontSize: 11,
fontWeight: '700',
},
messageRow: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 10,
marginVertical: 4,
},
messageRowLeft: {
alignSelf: 'flex-start',
maxWidth: '85%',
},
messageRowRight: {
alignSelf: 'flex-end',
flexDirection: 'row-reverse',
maxWidth: '85%',
},
chatAvatar: {
width: 32,
height: 32,
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
},
bubble: {
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 18,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 1,
},
bubbleLeft: {
borderBottomLeftRadius: 3,
},
bubbleRight: {
borderBottomRightRadius: 3,
},
bubbleText: {
fontSize: 15,
lineHeight: 21,
fontWeight: '600',
},
bubbleSub: {
fontSize: 12,
marginTop: 4,
color: '#8d9382',
},
typingContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
width: 36,
height: 18,
justifyContent: 'center',
},
typingDot: {
width: 6,
height: 6,
borderRadius: 3,
},
inputPanel: {
flexShrink: 1,
maxHeight: '58%',
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(255,255,255,0.08)',
backgroundColor: '#131e14',
},
inputPanelContent: {
paddingTop: 12,
paddingBottom: 24,
paddingHorizontal: 16,
},
optionsWrap: {
gap: 10,
},
stepTitle: {
color: '#8d9382',
fontSize: 12,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: 4,
textAlign: 'center',
},
optionsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
justifyContent: 'center',
},
pillBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 999,
borderWidth: 1.5,
backgroundColor: '#101e11',
},
pillEmoji: {
fontSize: 14,
},
pillLabel: {
fontSize: 13.5,
fontWeight: '700',
},
optionsVertical: {
gap: 8,
},
optionRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 14,
borderRadius: 14,
borderWidth: 1,
backgroundColor: '#101e11',
},
rowText: {
fontSize: 14.5,
fontWeight: '700',
},
bentoOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 14,
borderWidth: 1,
backgroundColor: '#101e11',
},
bentoCopy: {
flex: 1,
gap: 2,
marginRight: 12,
},
bentoTitle: {
fontSize: 15,
fontWeight: '700',
},
bentoSub: {
fontSize: 11.5,
fontWeight: '600',
lineHeight: 15,
},
footerWrap: {
paddingVertical: 8,
},
continueCta: {
minHeight: 56,
paddingVertical: 16,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
continueCtaText: {
fontSize: 16.5,
fontWeight: '900',
},
});