import { useCallback, useEffect, useRef, useState } from 'react'; import { View, Text, TextInput, FlatList, Pressable, Platform, Animated, Keyboard, KeyboardAvoidingView, StyleSheet, NativeSyntheticEvent, NativeScrollEvent, ActivityIndicator, AppState, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av'; // TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14 import * as FileSystem from 'expo-file-system/legacy'; import Constants from 'expo-constants'; import { useTranslation } from 'react-i18next'; import { RiveAvatar } from '../components/RiveAvatar'; import { apiFetch } from '../lib/api'; import { supabase } from '../lib/supabase'; import { useColors } from '../lib/theme'; import { type GameType, GAME_META, MemoryGame, TicTacToeGame, SnakeGame, TetrisGame, } from '../components/urge/UrgeGames'; import { SosFeedbackModal, type SosFeedback } from '../components/urge/SosFeedbackModal'; import { ShareSuccessDrawer } from '../components/urge/ShareSuccessDrawer'; import { InlineRatingDrawer } from '../components/urge/InlineRatingDrawer'; import { BreathingDrawer } from '../components/urge/Breathing'; import GamePickerDrawer from '../components/urge/GamePickerDrawer'; import { VoiceBars } from '../components/urge/InlineIndicators'; import MessageRow, { GameHeader, type SosMsg } from '../components/urge/MessageRow'; import { SOS_BOOT } from '../lib/sosPrompts'; import { CHIP_SETS, BREATH_PHASES, type ChipSet } from '../lib/sosConstants'; import { parseLyraResponse, detectEmotion, type LyraEmotion, type ChipSpec } from '../lib/lyraResponse'; import { streamSosLyra } from '../lib/sosStream'; import { SosTtsQueue } from '../lib/sosTtsQueue'; import { endpointForProvider, useTtsProvider, currentProvider } from '../lib/ttsProvider'; import { currentLlmProvider } from '../lib/llmProvider'; import { BenchSession } from '../lib/sosTtsBenchmark'; // ── Main Screen ─────────────────────────────────────────────────────────────── export default function SOSScreen() { const { t, i18n } = useTranslation(); const router = useRouter(); const insets = useSafeAreaInsets(); const colors = useColors(); const st = makeStyles(colors); const flatRef = useRef(null); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isBreathing, setIsBreathing] = useState(false); const [input, setInput] = useState(''); const [thinking, setThinking] = useState(false); const [emotion, setEmotion] = useState('idle'); const [isSpeaking, setIsSpeaking] = useState(false); const [isTtsLoading, setIsTtsLoading] = useState(false); const [soundEnabled, setSoundEnabled] = useState(true); const soundEnabledRef = useRef(true); const [chipSet, setChipSet] = useState('start'); const [dynamicChips, setDynamicChips] = useState([]); const [userTurnCount, setUserTurnCount] = useState(0); // ——— Session-Tracking für DiGA ——— const sessionStartRef = useRef(new Date()); const breathingCountRef = useRef(0); const gamesPlayedRef = useRef>([]); const wasOvercomeRef = useRef(false); const sessionSavedRef = useRef(false); // Hint-Trigger: User klickt "weiter reden" nach Atmen/Spiel const requestStatsReminderRef = useRef(false); // Hint-Trigger: nach Atmen/Spiel → Lyra soll Check-in machen const requestCheckInRef = useRef<'after_breathing' | 'after_game' | null>(null); // Hint-Trigger: nach Überwinden → Lyra soll zu Share/Rate motivieren const requestOvercomeMotivationRef = useRef(false); // Game-Score-Tracking: PB vor dem aktuellen Spielstart (für Vergleich nach Spiel-Ende) const pbBeforeGameRef = useRef(0); // Hint-Trigger: nach Spiel-Ende mit neuem Personal-Best → Lyra soll feiern const requestNewPbCelebrationRef = useRef<{ game: string; oldScore: number; newScore: number } | null>(null); // Exit-Feedback-Modal const [feedbackVisible, setFeedbackVisible] = useState(false); const exitingRef = useRef(false); // Inline-Feedback (Drawer in der Chat-Session) — wenn gegeben, kein Exit-Modal mehr const inlineFeedbackRef = useRef(null); const [ratingDrawerVisible, setRatingDrawerVisible] = useState(false); // Share-Success Drawer const [shareDrawerVisible, setShareDrawerVisible] = useState(false); const [shareDraft, setShareDraft] = useState(''); const [shareGenerating, setShareGenerating] = useState(false); const sharePostedRef = useRef(false); const [isPickingGame, setIsPickingGame] = useState(false); const [breathingDone, setBreathingDone] = useState(false); const [playingGame, setPlayingGame] = useState(null); const [keyboardHeight, setKeyboardHeight] = useState(0); const [showScrollBtn, setShowScrollBtn] = useState(false); const ttsRef = useRef(null); const ttsAbortRef = useRef(null); // Phase B: sentence-level streaming TTS — eine Queue pro sendToLyra-Call. const ttsQueueRef = useRef(null); const emotionTimer = useRef | null>(null); const isNearBottomRef = useRef(true); useEffect(() => { soundEnabledRef.current = soundEnabled; }, [soundEnabled]); // ttsProvider state: Änderungen invalidieren den Atemübungs-Audio-Cache unten. const [ttsProvider] = useTtsProvider(); // Pre-cache der Atemübungs-Voice-Cues (Einatmen / Halten / Ausatmen). // Bei Phase-Wechsel im BreathingDrawer kommt das Audio sofort statt mit // ~600ms TTS-Roundtrip — so bleibt Voice synchron mit der Pulse-Animation. // Cache wird invalidiert wenn der TTS-Provider wechselt. const breathAudioCacheRef = useRef>(new Map()); useEffect(() => { breathAudioCacheRef.current.clear(); const phrases = BREATH_PHASES .map((p) => p.speakLine) .filter((s): s is string => Boolean(s)); let cancelled = false; (async () => { for (const text of phrases) { if (cancelled) return; const audio = await fetchTtsAudio(text).catch(() => null); if (cancelled) return; if (audio) breathAudioCacheRef.current.set(text, audio.uri); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ttsProvider]); // Audio-Mode: bei SOS-Mount Audio-Session konfigurieren. // - playsInSilentModeIOS: Lyra spricht auch wenn iPhone auf "stumm" // - shouldDuckAndroid: andere Audio-Quellen (Spotify) leiser machen wenn Lyra spricht // - staysActiveInBackground: false → OS pausiert TTS automatisch wenn App im Background // Plus warm-up: Audio.Sound.createAsync hat auf Android ~500ms Cold-Start. // Wir feuern einen no-op-load + unload damit ExoPlayer warm ist bevor Lyra spricht. useEffect(() => { Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: true, staysActiveInBackground: false, interruptionModeIOS: InterruptionModeIOS.DoNotMix, shouldDuckAndroid: true, interruptionModeAndroid: InterruptionModeAndroid.DoNotMix, playThroughEarpieceAndroid: false, }).catch((err) => { console.warn('[sos-audio-mode] failed:', err); }); }, []); // Cleanup-Effect: bei Component-Unmount (router.back, hardware-back, replace, // ...) ALLE TTS-Resources freigeben — damit Lyra nicht im Background weiter // redet wenn der User die SOS-Page verlässt. User-Bug 2026-05-04 auf A50. useEffect(() => { return () => { ttsAbortRef.current?.abort(); ttsAbortRef.current = null; if (ttsRef.current) { const s = ttsRef.current; ttsRef.current = null; s.stopAsync().catch(() => {}); s.unloadAsync().catch(() => {}); } ttsQueueRef.current?.abort(); ttsQueueRef.current = null; if (emotionTimer.current) { clearTimeout(emotionTimer.current); emotionTimer.current = null; } }; }, []); // AppState: wenn App in den Background geht (Home-Button, App-Switcher, // Lock-Screen) → TTS stoppen. Sonst spricht Lyra durch's Lock-Screen weiter. useEffect(() => { const sub = AppState.addEventListener('change', (state) => { if (state === 'background' || state === 'inactive') { ttsAbortRef.current?.abort(); ttsAbortRef.current = null; if (ttsRef.current) { const s = ttsRef.current; ttsRef.current = null; s.stopAsync().catch(() => {}); s.unloadAsync().catch(() => {}); } ttsQueueRef.current?.abort(); ttsQueueRef.current = null; setIsSpeaking(false); setIsTtsLoading(false); } }); return () => sub.remove(); }, []); useEffect(() => { const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; const show = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height)); const hide = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0)); return () => { show.remove(); hide.remove(); }; }, []); useEffect(() => { if (messages.length === 0) return; if (isNearBottomRef.current) requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true })); else setShowScrollBtn(true); }, [messages.length, thinking, isBreathing]); function handleScroll(e: NativeSyntheticEvent) { const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent; const dist = contentSize.height - contentOffset.y - layoutMeasurement.height; isNearBottomRef.current = dist < 80; if (isNearBottomRef.current) setShowScrollBtn(false); } function addMessage(msg: SosMsg) { setMessages((prev) => [...prev, msg]); } function scheduleEmotionReset(delay = 4000) { if (emotionTimer.current) clearTimeout(emotionTimer.current); emotionTimer.current = setTimeout(() => setEmotion('idle'), delay); } function stopSpeaking() { ttsAbortRef.current?.abort(); ttsAbortRef.current = null; ttsRef.current?.stopAsync().catch(() => {}); ttsRef.current?.unloadAsync().catch(() => {}); ttsRef.current = null; // Phase B: laufende Sentence-Queue auch abbrechen ttsQueueRef.current?.abort(); ttsQueueRef.current = null; setIsSpeaking(false); setIsTtsLoading(false); setEmotion('idle'); } // Holt Audio-MP3 von speak-openai und speichert als temporäre Datei async function fetchTtsAudio(rawText: string): Promise<{ uri: string; controller: AbortController } | null> { if (!soundEnabledRef.current) return null; const text = rawText.replace(/\s+/g, ' ').trim(); if (!text) return null; const controller = new AbortController(); const session = (await supabase.auth.getSession()).data.session; if (controller.signal.aborted) return null; const apiBase = Constants.expoConfig?.extra?.apiUrl as string; const endpoint = '/api/coach/speak'; const isGoogleCloud = false; const ttsRes = await fetch(`${apiBase}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}), }, body: JSON.stringify({ text, locale: i18n.language, mode: 'sos' }), signal: controller.signal, }); if (!ttsRes.ok || controller.signal.aborted) return null; let base64: string; let ext: 'mp3' | 'wav'; if (isGoogleCloud) { const json = (await ttsRes.json()) as { audio?: string }; const dataUri = json.audio ?? ''; const comma = dataUri.indexOf(','); if (comma === -1) return null; base64 = dataUri.slice(comma + 1); ext = 'mp3'; } else { const buffer = await ttsRes.arrayBuffer(); if (controller.signal.aborted || buffer.byteLength === 0) return null; const bytes = new Uint8Array(buffer); const chunks: string[] = []; const cs = 0x8000; for (let i = 0; i < bytes.length; i += cs) chunks.push(String.fromCharCode(...bytes.subarray(i, Math.min(i + cs, bytes.length)))); base64 = btoa(chunks.join('')); ext = endpoint.endsWith('/speak-gemini') ? 'wav' : 'mp3'; } const tmpPath = `${FileSystem.cacheDirectory}sos-tts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; await FileSystem.writeAsStringAsync(tmpPath, base64, { encoding: FileSystem.EncodingType.Base64 }); if (controller.signal.aborted) return null; return { uri: tmpPath, controller }; } // Spielt eine fertige Audio-Datei (oder fetcht sie wenn nicht vorhanden) und wartet auf didJustFinish async function playTtsAudio(rawText: string, prefetched: { uri: string; controller: AbortController } | null): Promise { if (!soundEnabledRef.current) return; // Bestehenden Audio NICHT abbrechen — wir wollen nahtlos anschließen setIsTtsLoading(!prefetched); let audio = prefetched; if (!audio) { try { audio = await fetchTtsAudio(rawText); } catch { audio = null; } } if (!audio) { setIsTtsLoading(false); return; } ttsAbortRef.current = audio.controller; try { const { sound } = await Audio.Sound.createAsync({ uri: audio.uri }, { shouldPlay: true }); if (audio.controller.signal.aborted) { sound.unloadAsync(); setIsTtsLoading(false); return; } setIsTtsLoading(false); ttsRef.current = sound; setIsSpeaking(true); await new Promise((resolve) => { const aborted = () => { resolve(); }; audio!.controller.signal.addEventListener('abort', aborted); sound.setOnPlaybackStatusUpdate((status) => { if (status.isLoaded && status.didJustFinish) { setIsSpeaking(false); scheduleEmotionReset(0); sound.unloadAsync().catch(() => {}); audio!.controller.signal.removeEventListener('abort', aborted); resolve(); } }); }); } catch (err: any) { if (err?.name === 'AbortError' || audio.controller.signal.aborted) { setIsTtsLoading(false); return; } console.warn('[sos-tts]', err); setIsTtsLoading(false); setIsSpeaking(false); } } // Legacy: einmaliger TTS-Call (für nicht-Streaming Pfade z.B. opening greeting) async function speakText(rawText: string): Promise { if (!soundEnabledRef.current) return; ttsAbortRef.current?.abort(); if (ttsRef.current) { ttsRef.current.stopAsync().catch(() => {}); ttsRef.current.unloadAsync().catch(() => {}); ttsRef.current = null; setIsSpeaking(false); } // Cache-hit (Atemübung-Phrasen): instant playback, kein API-roundtrip. const cleaned = rawText.replace(/\s+/g, ' ').trim(); const cachedUri = breathAudioCacheRef.current.get(cleaned); if (cachedUri) { await playTtsAudio(cleaned, { uri: cachedUri, controller: new AbortController() }); return; } const audio = await fetchTtsAudio(rawText).catch(() => null); if (!audio) return; await playTtsAudio(rawText, audio); } async function sendToLyra(userText: string) { if (thinking) return; if (isSpeaking) stopSpeaking(); addMessage({ id: Date.now().toString(), role: 'user', content: userText, timestamp: new Date() }); setUserTurnCount((n) => n + 1); setThinking(true); setEmotion('thinking'); // Latenz-Benchmark — eine Session pro sendToLyra-Call. Marker werden in // stream/queue über onMetric gesammelt, gedruckt im onIdle (oder als // Fallback im finally bei Errors / sound-off). const bench = new BenchSession({ provider: currentProvider(), llm: currentLlmProvider(), label: 'send' }); try { const visibleHistory = messages.filter((m) => !m.cardType).map((m) => ({ role: m.role, content: m.content })); // ----- Hint-Builder ----- const hints: string[] = []; // (1) Check-in nach Atmen/Spiel const checkIn = requestCheckInRef.current; requestCheckInRef.current = null; if (checkIn === 'after_breathing') { hints.push('[SYSTEM-HINT] Der User hat gerade die Atemübung beendet. Frage warm nach, wie er sich JETZT fühlt. Biete 3-4 Chips: "🫁 Nochmal atmen", "🎮 Spiel", "💭 An was anderes denken", "😄 Erzähl mir einen Witz".'); } else if (checkIn === 'after_game') { hints.push('[SYSTEM-HINT] Der User hat gerade das Spiel beendet. Frage warm, wie er sich JETZT fühlt. Biete 3-4 Chips: "🎮 Weiter spielen", "🫁 Atemübung", "💭 An was anderes denken", "😄 Erzähl mir einen Witz".'); } // (1b) Personal-Best-Celebration — wenn User gerade neuen PB gemacht hat if (requestNewPbCelebrationRef.current) { const { game, oldScore, newScore } = requestNewPbCelebrationRef.current; requestNewPbCelebrationRef.current = null; const pbHint = oldScore > 0 ? `[SYSTEM-HINT] NEUER REKORD! Der User hat sein bisheriges Personal-Best in ${game} (${oldScore}) auf ${newScore} verbessert. Feiere das warm in 1-2 Sätzen — nenne KONKRET die Zahlen ${oldScore} → ${newScore}. Mach klar: das ist sein eigener Sieg, ein echter Schritt. Dann der normale after_game-Check-in (siehe oben).` : `[SYSTEM-HINT] Erster Score! Der User hat seinen ALLERSTEN Score in ${game}: ${newScore}. Feiere den Erstversuch warm — der erste Score ist immer was Besonderes. Dann der normale after_game-Check-in.`; hints.push(pbHint); } // (2) Stats-Reminder bei "weiter reden" nach Aktion if (requestStatsReminderRef.current) { requestStatsReminderRef.current = false; const b = breathingCountRef.current; const g = gamesPlayedRef.current.length; const parts: string[] = []; if (b > 0) parts.push(`${b}x Atemübung`); if (g > 0) parts.push(`${g}x Spiel`); const stats = parts.join(' + ') || 'mehrere Bewältigungs-Tools'; hints.push(`[SYSTEM-HINT] Erinnere den User WARM und KONKRET: Wir haben in dieser Session schon ${stats} gemacht. Das senkt wissenschaftlich nachweislich den Spielimpuls (z.B. Atemübungen aktivieren den Parasympathikus). Lobe ihn: er hat die Gambling-Industrie heute schon geschlagen. Dann frage, was er jetzt braucht. Chips: "❤️ Überwunden", "🫁 Nochmal atmen", "🎮 Spiel".`); } // (3) Nach 3+ Turns: Service-Angebot wenn Lyra noch nicht angeboten hat if ((userTurnCount + 1) >= 3 && breathingCountRef.current === 0 && gamesPlayedRef.current.length === 0) { hints.push('[SYSTEM-HINT] Ich habe jetzt schon mehrere Male geantwortet. Biete mir JETZT konkret eine Atemübung ODER ein Spiel an. Chips müssen "breathing" + "game_picker" enthalten, optional "send_text:Lass uns weiter reden".'); } // (4) Nach Überwinden → Lyra motiviert zu Sharing + Bewertung if (requestOvercomeMotivationRef.current) { requestOvercomeMotivationRef.current = false; hints.push( '[SYSTEM-HINT] Der User hat den Impuls ÜBERWUNDEN. Feiere ihn warm und KURZ (2-3 Sätze max). Dann motiviere ihn zu ZWEI konkreten Aktionen: ' + '(a) Erfolg mit der Community teilen — das stärkt andere Betroffene und macht ihn stolz. ' + '(b) Diese Session bewerten + kurze Bemerkung geben — das hilft uns Lyra zu verbessern und die App für DiGA-Zertifizierung qualitativ besser zu machen. ' + 'WICHTIG: Liefere GENAU diese Chips: ' + '[{"label":"✨ Erfolg teilen","action":"share_success"}, {"label":"⭐ Session bewerten","action":"rate_session"}, {"label":"✅ Fertig","action":"close"}]' ); } const hintMsgs = hints.map((h) => ({ role: 'user' as const, content: h })); const apiMessages = [...SOS_BOOT, ...visibleHistory, { role: 'user' as const, content: userText }, ...hintMsgs]; // ── SSE-Streaming via react-native-sse ── const session = (await supabase.auth.getSession()).data.session; const apiBase = Constants.expoConfig?.extra?.apiUrl as string; if (!session?.access_token) throw new Error('no token'); const assistantId = (Date.now() + 1).toString(); let assistantAdded = false; const ensureBubble = (text: string) => { if (!assistantAdded) { assistantAdded = true; addMessage({ id: assistantId, role: 'assistant', content: text, timestamp: new Date() }); } else { setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: text } : m))); } }; let visible = ''; let parsedChips: Array<{ label: string; action: string }> = []; let streamError: any = null; // Hybrid-TTS v3 (Threshold 3): warte auf 3 Sätze bevor first-chunk // ans TTS geht. Mit prompt "max 3 Sätze" sind 99% aller Antworten // single-shot → NULL Voice-Boundary, 100% konsistente Stimme. // Trade-off: ~1s mehr First-Audio-Latenz im 3-Satz-Fall vs. v2. const firstChunkSentences: string[] = []; let firstChunkConsumed = false; let firstChunkText = ''; // Hybrid-TTS-Queue: erster Satz live + Rest als ein Block. Pre-fetch in // der Queue startet sofort beim enqueue → läuft parallel zum Playback // des ersten Satzes → null Gap zwischen Satz-1 und Rest. ttsQueueRef.current?.abort(); const ttsQueue = soundEnabledRef.current ? new SosTtsQueue({ apiBase, accessToken: session.access_token, locale: i18n.language, endpoint: '/api/coach/speak', onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); }, onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); bench.print(); }, onError: (err, sentence) => { console.warn('[sos-tts-queue] segment failed:', sentence.slice(0, 50), err); }, onMetric: bench.mark, }) : null; ttsQueueRef.current = ttsQueue; if (ttsQueue) setIsTtsLoading(true); await new Promise((resolve) => { let cancel: (() => void) | undefined; streamSosLyra({ apiBase, token: session.access_token, messages: apiMessages, locale: i18n.language, llmProvider: currentLlmProvider(), onMetric: bench.mark, onTextUpdate: (full) => { visible = full; ensureBubble(full); }, onChips: (chips) => { parsedChips = chips; }, onSentence: (sentence) => { if (firstChunkConsumed) return; firstChunkSentences.push(sentence); // Trigger erst bei 3 vollständigen Sätzen (war v2: 2). Mit Server- // Prompt "max 3 Sätze" landen 99% aller Antworten als single-shot // im onDone (count<3) → NULL Boundary, 100% konsistente Stimme. // Boundary nur bei seltenen 4+-Satz-Antworten. if (firstChunkSentences.length >= 2) { firstChunkConsumed = true; firstChunkText = firstChunkSentences.join(' '); ttsQueue?.enqueue(firstChunkText); } }, onDone: (full) => { visible = full || visible; ensureBubble(visible); if (ttsQueue) { if (firstChunkConsumed) { // Rest = full minus first-chunk. Fester Match via indexOf. const idx = full.indexOf(firstChunkText); const rest = idx >= 0 ? full.slice(idx + firstChunkText.length).trim() : full.replace(firstChunkText, '').trim(); if (rest.length > 0) { // Mode 'sos-continuation' → server gibt OpenAI explizite // "no fresh-start"-Instructions → Boundary weicher. ttsQueue.enqueue(rest, 'sos-continuation'); } } else { // <2 Sätze detektiert (kurze Antwort) — kompletten Text als // single-shot. Voice 100% konsistent, kein Boundary überhaupt. const cleaned = (full || visible).trim(); if (cleaned) ttsQueue.enqueue(cleaned); } } resolve(); }, onError: (err) => { streamError = err; resolve(); }, }) .then((c) => { cancel = c; }) .catch((err) => { streamError = err; resolve(); }); }); // Fallback bei Stream-Fehler: alter non-streaming Endpoint if (streamError) { console.warn('[sos-stream] failed, fallback', streamError); // Queue abbrechen damit nicht halb-gefüllter Stream noch Audio spielt ttsQueueRef.current?.abort(); ttsQueueRef.current = null; const res = await apiFetch<{ message: string }>('/api/coach/message', { method: 'POST', body: { messages: apiMessages, locale: i18n.language, sosMode: true }, }); const parsed = parseLyraResponse(res?.message ?? ''); visible = parsed.message; parsedChips = parsed.chips; ensureBubble(visible); if (visible) speakText(visible); } // Wenn Stream erfolgreich war, Queue aber leer geblieben (z.B. Stream hat // 0 Sätze geliefert, Edge-Case bei sehr kurzer LLM-Antwort) → Loading-Flag // selbst zurücksetzen, sonst hängt der Spinner. if (!streamError && ttsQueue && !ttsQueue.isActive()) { setIsTtsLoading(false); } const e = detectEmotion(visible); // Fallback-Chips wenn Lyra keine liefert let finalChips = parsedChips; if (finalChips.length === 0) { if (checkIn === 'after_breathing') { finalChips = [ { label: '🫁 Nochmal atmen', action: 'breathing' }, { label: '🎮 Spiel', action: 'game_picker' }, { label: '💭 An was anderes denken', action: 'send_text:Lenk mich bitte ab — erzähl mir was Schönes.' }, { label: '❤️ Überwunden', action: 'overcome' }, ]; } else if (checkIn === 'after_game') { finalChips = [ { label: '🎮 Weiter spielen', action: 'game_picker' }, { label: '🫁 Atemübung', action: 'breathing' }, { label: '😄 Erzähl mir einen Witz', action: 'send_text:Erzähl mir bitte einen kurzen Witz.' }, { label: '❤️ Überwunden', action: 'overcome' }, ]; } else if ((userTurnCount + 1) >= 3) { finalChips = [ { label: '🫁 Atemübung', action: 'breathing' }, { label: '🎮 Spiel', action: 'game_picker' }, { label: '💬 Weiter reden', action: 'send_text:Lass uns weiter reden.' }, ]; } else { finalChips = [ { label: '💬 Mehr erzählen', action: 'send_text:Ich möchte mehr erzählen.' }, { label: '🫁 Atemübung', action: 'breathing' }, { label: '🎮 Ablenken', action: 'game_picker' }, ]; } } setDynamicChips(finalChips); setChipSet('none'); setEmotion(e); scheduleEmotionReset(e === 'empathy' ? 6000 : 4000); } catch { addMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: t('coach.error'), timestamp: new Date() }); setEmotion('idle'); } finally { setThinking(false); // Fallback-Print NUR wenn keine TTS-Queue (mehr) aktiv ist. Sonst feuert // das finally bei kurzen Antworten zu früh — der TTS-Fetch läuft dann // gerade erst, headers kommen erst Sekunden später, und ein print() // hier würde alle TTS-Marker verwerfen. Im aktiven Fall übernimmt // ttsQueue.onIdle den Print. if (!ttsQueueRef.current?.isActive()) { bench.print('finally'); } } } // Opening greeting on mount — nutzt gleichen Streaming-Pfad wie sendToLyra, // damit Hybrid-TTS auch beim Start greift (sonst dauerte es 4-5s bis Lyra // sprach, weil non-streaming /api/coach/message + dann separater TTS-Call). useEffect(() => { let cancelled = false; async function openGreeting() { setIsLoading(true); setEmotion('thinking'); setThinking(true); const greetingId = 'greeting-' + Date.now(); let assistantAdded = false; const ensureBubble = (text: string) => { if (!assistantAdded) { assistantAdded = true; addMessage({ id: greetingId, role: 'assistant', content: text, timestamp: new Date() }); } else { setMessages((prev) => prev.map((m) => (m.id === greetingId ? { ...m, content: text } : m))); } }; try { const session = (await supabase.auth.getSession()).data.session; if (cancelled) return; if (!session?.access_token) throw new Error('no token'); const apiBase = Constants.expoConfig?.extra?.apiUrl as string; // Latenz-Benchmark fürs Greeting — gleiches Pattern wie sendToLyra. const greetingBench = new BenchSession({ provider: currentProvider(), llm: currentLlmProvider(), label: 'greeting' }); // Hybrid-TTS-Queue, gleiches Pattern wie sendToLyra const ttsQueue = soundEnabledRef.current ? new SosTtsQueue({ apiBase, accessToken: session.access_token, locale: i18n.language, endpoint: '/api/coach/speak', onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); }, onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); greetingBench.print(); }, onError: (err, sentence) => { console.warn('[sos-tts-greeting] segment failed:', sentence.slice(0, 50), err); }, onMetric: greetingBench.mark, }) : null; ttsQueueRef.current?.abort(); ttsQueueRef.current = ttsQueue; if (ttsQueue) setIsTtsLoading(true); const greetingChunkSentences: string[] = []; let greetingChunkConsumed = false; let greetingChunkText = ''; let visible = ''; let parsedChips: Array<{ label: string; action: string }> = []; await new Promise((resolve) => { streamSosLyra({ apiBase, token: session.access_token, messages: SOS_BOOT, locale: i18n.language, llmProvider: currentLlmProvider(), onMetric: greetingBench.mark, onTextUpdate: (full) => { if (cancelled) return; visible = full; ensureBubble(full); }, onChips: (chips) => { parsedChips = chips; }, onSentence: (s) => { if (greetingChunkConsumed) return; greetingChunkSentences.push(s); if (greetingChunkSentences.length >= 2) { greetingChunkConsumed = true; greetingChunkText = greetingChunkSentences.join(' '); ttsQueue?.enqueue(greetingChunkText); } }, onDone: (full) => { if (cancelled) { resolve(); return; } visible = full || visible; ensureBubble(visible); if (ttsQueue) { if (greetingChunkConsumed) { const idx = full.indexOf(greetingChunkText); const rest = idx >= 0 ? full.slice(idx + greetingChunkText.length).trim() : full.replace(greetingChunkText, '').trim(); if (rest.length > 0) ttsQueue.enqueue(rest, 'sos-continuation'); } else { const cleaned = (full || visible).trim(); if (cleaned) ttsQueue.enqueue(cleaned); } } resolve(); }, onError: (err) => { console.warn('[sos-greeting] stream failed:', err); resolve(); }, }) .then(() => {}) .catch(() => resolve()); }); if (cancelled) return; const e = detectEmotion(visible); // Fallback-Chips falls Lyra im Greeting keine liefert const greetingChips = parsedChips.length > 0 ? parsedChips : [ { label: '😤 Wütend', action: 'feel:Ich bin gerade sehr wütend.' }, { label: '😰 Ängstlich', action: 'feel:Ich bin ängstlich und nervos.' }, { label: '😔 Traurig', action: 'feel:Ich bin traurig.' }, { label: '🤔 Etwas anderes', action: 'need_help' }, ]; setDynamicChips(greetingChips); setChipSet('none'); setEmotion(e); scheduleEmotionReset(e === 'empathy' ? 6000 : 4000); } catch (err) { if (cancelled) return; console.warn('[sos-greeting] failed, fallback message', err); addMessage({ id: 'greeting-err', role: 'assistant', content: 'Ich bin für dich da. Was ist gerade los?', timestamp: new Date() }); setEmotion('empathy'); scheduleEmotionReset(5000); } finally { if (!cancelled) { setIsLoading(false); setThinking(false); } } } openGreeting(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function startBreathing() { // Laufendes Lyra-TTS stoppen — sonst spricht sie in die Atemübung rein stopSpeaking(); setIsBreathing(true); setChipSet('none'); } async function handleBreathingDone() { setIsBreathing(false); setBreathingDone(true); breathingCountRef.current += 1; setChipSet('after_breathing'); requestCheckInRef.current = 'after_breathing'; await sendToLyra('Ich habe gerade die 4-7-8 Atemübung abgeschlossen.'); } async function handleChip(action: string) { if (thinking) return; Keyboard.dismiss(); // Clear dynamic chips on any chip action — Lyra will provide new ones in her reply setDynamicChips([]); if (action.startsWith('feel:')) { const feelingText = action.replace('feel:', ''); setChipSet('none'); await sendToLyra(feelingText); } else if (action === 'need_help') { setChipSet('none'); await sendToLyra('Ich möchte darüber reden, was gerade los ist.'); } else if (action === 'just_play') { setChipSet('none'); setIsPickingGame(true); } else if (action === 'breathing') { startBreathing(); } else if (action === 'game_picker') { setChipSet('none'); setIsPickingGame(true); } else if (action === 'overcome') { await finalizeOvercome(); } else if (action === 'show_stats') { setChipSet('none'); try { const res = await apiFetch<{ urges?: number; overcomeCnt?: number; streakDays?: number }>('/api/urge/stats'); const msg = res ? `Du hast heute ${res.overcomeCnt ?? 1} von ${res.urges ?? 1} Impulsen widerstanden. Dein Streak: ${res.streakDays ?? 1} Tag(e). Ich bin stolz auf dich! 💪` : 'Jeder überwundene Impuls macht dich stärker. Ich bin stolz auf dich! 💪'; addMessage({ id: 'stats-' + Date.now(), role: 'assistant', content: msg, timestamp: new Date() }); speakText(msg); } catch { addMessage({ id: 'stats-err', role: 'assistant', content: 'Jeder überwundene Impuls zählt. Du machst das großartig! 💪', timestamp: new Date() }); } } else if (action === 'close') { attemptExit(); } else if (action === 'share_success') { setChipSet('none'); openShareDrawer(); } else if (action === 'rate_session') { setChipSet('none'); setRatingDrawerVisible(true); } else if (action.startsWith('send_text:')) { setChipSet('none'); const txt = action.replace('send_text:', ''); // Wenn der User nach Atem/Spiel "weiter reden" klickt → Stats-Reminder triggern if ((breathingCountRef.current > 0 || gamesPlayedRef.current.length > 0) && /weiter reden|weiterreden|reden|sprechen/i.test(txt)) { requestStatsReminderRef.current = true; } await sendToLyra(txt); } } async function handleGameSelect(game: GameType) { setIsPickingGame(false); setPlayingGame(game); const titles: Record = { snake: 'Snake', tetris: 'Tetris', memory: 'Memory', tictactoe: 'Tic-Tac-Toe' }; const title = titles[game]; // Personal-Best fetchen (parallel zum Game-Start) — Lyra mentions PB im Pep-Talk. // Cache pbBeforeGame für späteren Vergleich nach Spiel-Ende (Rekord-Detection). let pbContext = ''; pbBeforeGameRef.current = 0; try { const pbRes = await apiFetch<{ score: number; hasRecord: boolean }>(`/api/games/highscore?gameName=${encodeURIComponent(game)}`); const pb = pbRes?.score ?? 0; pbBeforeGameRef.current = pb; if (pbRes?.hasRecord && pb > 0) { pbContext = ` Sein bisheriger Personal-Best in ${title} ist ${pb}. Erwähne diesen PB konkret im Kommentar UND glaube an ihn dass er es heute schaffen kann.`; } else { pbContext = ` Es ist sein ALLERSTER ${title}-Versuch. Ermutige zum Erstversuch.`; } } catch { // Silent fallback — Lyra bekommt einfach keinen PB-Hint } // Fire-and-forget Lyra-Kommentar (UI launches game immediately) (async () => { try { const promptMsg = `[INTERN: Der User hat das Spiel "${title}" gewählt.${pbContext} Gib EINEN warmen Kommentar (1-2 Sätze) — bei Tetris nostalgisch, Snake spielerisch, Memory ermutigend, Tic-Tac-Toe entspannt. Antworte als reines JSON: {"message":"...","chips":[]}. Kein Markdown.]`; const visibleHistory = messages.filter((m) => !m.cardType).map((m) => ({ role: m.role, content: m.content })); const apiMessages = [...SOS_BOOT, ...visibleHistory, { role: 'user' as const, content: promptMsg }]; const res = await apiFetch<{ message: string }>('/api/coach/message', { method: 'POST', body: { messages: apiMessages, locale: i18n.language, sosMode: true }, }); const parsed = parseLyraResponse(res?.message ?? ''); const visible = parsed.message || `Viel Spaß mit ${title}!`; addMessage({ id: 'gamecomment-' + Date.now(), role: 'assistant', content: visible, timestamp: new Date() }); setEmotion('happy'); scheduleEmotionReset(4000); speakText(visible); } catch { const fallback = pbBeforeGameRef.current > 0 ? `Viel Spaß mit ${title}! Dein PB ist ${pbBeforeGameRef.current} — ich glaube du schaffst heute mehr.` : `Viel Spaß mit ${title}! Ich bin hier, wenn du fertig bist.`; addMessage({ id: 'gamecomment-err-' + Date.now(), role: 'assistant', content: fallback, timestamp: new Date() }); speakText(fallback); } })(); } async function handleGameComplete(score: number) { const gameName = GAME_META.find((g) => g.id === playingGame)?.id ?? 'das Spiel'; const game = playingGame ?? 'unknown'; gamesPlayedRef.current.push({ game, score, durationSec: 0 }); // Score persistieren — server entscheidet ob's ein neuer PB ist (idempotent) let isNewBest = false; try { const res = await apiFetch<{ ok: boolean; isNewBest: boolean }>('/api/games/score', { method: 'POST', body: { gameName: game, score }, }); isNewBest = !!res?.isNewBest; } catch (err) { console.warn('[urge] score submit failed', err); } // Wenn Rekord: Lyra im nächsten Reply feiern lassen via Hint-Trigger const oldPb = pbBeforeGameRef.current; if (isNewBest && score > oldPb) { requestNewPbCelebrationRef.current = { game: gameName, oldScore: oldPb, newScore: score }; } setPlayingGame(null); setChipSet('after_game'); requestCheckInRef.current = 'after_game'; await sendToLyra(`Ich habe ${gameName} gespielt und ${score} Punkte erreicht.`); } async function handleGameAbandon() { const game = playingGame; if (game) gamesPlayedRef.current.push({ game, score: 0, durationSec: 0 }); setPlayingGame(null); setChipSet('after_game'); requestCheckInRef.current = 'after_game'; await sendToLyra('Ich habe das Spiel abgebrochen.'); } async function finalizeOvercome() { if (thinking) return; setChipSet('none'); wasOvercomeRef.current = true; try { await apiFetch('/api/urge', { method: 'POST', body: { emotion: 'other', wasOvercome: true, breathingDone } }); } catch {} // Lyra antwortet warm + motiviert zu Share/Rate requestOvercomeMotivationRef.current = true; await sendToLyra('Ich habe den Spielimpuls gerade überwunden – ich bin so erleichtert und stolz auf mich!'); addMessage({ id: 'overcome-' + Date.now(), role: 'assistant', content: '', cardType: 'overcome', timestamp: new Date() }); // Chips garantiert anzeigen — egal was Lyra zurückgibt (Hint kann verfehlt werden) setDynamicChips([ { label: '✨ Erfolg teilen', action: 'share_success' }, { label: '⭐ Session bewerten', action: 'rate_session' }, { label: '✅ Fertig', action: 'close' }, ]); setChipSet('none'); } // ───── Share-Success Drawer ───── async function generateShareDraft() { setShareGenerating(true); try { const b = breathingCountRef.current; const g = gamesPlayedRef.current.length; const stats: string[] = []; if (b > 0) stats.push(`${b}x Atemübung`); if (g > 0) stats.push(`${g}x Mini-Spiel`); const statsLine = stats.length > 0 ? stats.join(' + ') : 'Lyras Begleitung'; const promptMsg = `[INTERN: Schreibe einen kurzen, ehrlichen, warmen anonymen Community-Post (max 4 Sätze, ich-Form, Deutsch) über meinen heutigen Erfolg. ` + `Ich habe einen akuten Spielimpuls überwunden mit ${statsLine}. ` + `Inspiriere andere, ohne zu predigen. Kein Hashtag, keine Emojis-Spam (max 1 Emoji am Ende). ` + `Antworte als reines JSON: {"message":"","chips":[]}. Kein Markdown.]`; const visibleHistory = messages .filter((m) => !m.cardType && m.content) .slice(-10) .map((m) => ({ role: m.role, content: m.content })); const apiMessages = [...SOS_BOOT, ...visibleHistory, { role: 'user' as const, content: promptMsg }]; const res = await apiFetch<{ message: string }>('/api/coach/message', { method: 'POST', body: { messages: apiMessages, locale: i18n.language, sosMode: true }, }); const parsed = parseLyraResponse(res?.message ?? ''); const draft = parsed.message?.trim() || 'Heute hatte ich einen heftigen Spielimpuls — und ich habe ihn überwunden. Es war hart, aber ich bin geblieben. 💪'; setShareDraft(draft); } catch { setShareDraft( 'Heute hatte ich einen Spielimpuls — und ich habe ihn überwunden. Schritt für Schritt. 💪', ); } finally { setShareGenerating(false); } } async function openShareDrawer() { setShareDrawerVisible(true); setShareDraft(''); await generateShareDraft(); } async function submitSharePost(text: string) { if (sharePostedRef.current) return; sharePostedRef.current = true; try { await apiFetch('/api/community/post', { method: 'POST', body: { category: 'story', content: text }, }); addMessage({ id: 'share-ack-' + Date.now(), role: 'assistant', content: 'Dein Erfolg ist geteilt — danke, dass du andere stärkst. 💛', timestamp: new Date(), }); } catch { sharePostedRef.current = false; addMessage({ id: 'share-err-' + Date.now(), role: 'assistant', content: 'Das Teilen hat gerade nicht geklappt. Versuch es später nochmal.', timestamp: new Date(), }); } finally { setShareDrawerVisible(false); } } // ───── Inline-Rating Drawer ───── async function submitInlineRating(fb: SosFeedback) { inlineFeedbackRef.current = fb; setRatingDrawerVisible(false); addMessage({ id: 'rate-ack-' + Date.now(), role: 'assistant', content: 'Danke für dein Feedback — das hilft mir, besser zu werden. 💛', timestamp: new Date(), }); } async function handleSend() { const content = input.trim(); if (!content || thinking) return; setInput(''); if (chipSet === 'start') setChipSet('help'); await sendToLyra(content); } // ───── Exit + Session-Persist ───── function hasInteracted(): boolean { const userMsgs = messages.filter((m) => m.role === 'user').length; return ( userMsgs > 0 || breathingCountRef.current > 0 || gamesPlayedRef.current.length > 0 || wasOvercomeRef.current ); } function attemptExit() { if (exitingRef.current) return; if (isSpeaking) stopSpeaking(); // Wenn User schon inline bewertet hat → direkt speichern, kein Modal if (inlineFeedbackRef.current) { exitingRef.current = true; void persistSession(inlineFeedbackRef.current).finally(() => router.back()); return; } if (hasInteracted()) { setFeedbackVisible(true); } else { // Nur reingeschaut → kein Modal, kein DB-Save exitingRef.current = true; router.back(); } } async function persistSession(feedback: SosFeedback | null) { const endedAt = new Date(); const durationSec = Math.max( 1, Math.round((endedAt.getTime() - sessionStartRef.current.getTime()) / 1000), ); const payload = { startedAt: sessionStartRef.current.toISOString(), endedAt: endedAt.toISOString(), durationSec, messages: messages .filter((m) => !m.cardType && m.content) .map((m) => ({ role: m.role, content: m.content, timestamp: m.timestamp.toISOString(), })), gamesPlayed: gamesPlayedRef.current, breathingCount: breathingCountRef.current, wasOvercome: wasOvercomeRef.current, feedbackBetter: feedback?.better ?? null, feedbackRating: feedback?.rating ?? null, feedbackText: feedback?.text || null, locale: i18n.language, }; try { await apiFetch('/api/sos/session', { method: 'POST', body: payload }); } catch (err) { console.warn('[sos-session-save]', err); } } async function handleFeedbackSubmit(feedback: SosFeedback) { setFeedbackVisible(false); exitingRef.current = true; await persistSession(feedback); router.back(); } async function handleFeedbackSkip() { setFeedbackVisible(false); exitingRef.current = true; await persistSession(null); router.back(); } const currentChips = dynamicChips.length > 0 ? dynamicChips : (CHIP_SETS[chipSet] ?? []); const topBarHeight = insets.top + 160; const renderMessage = useCallback( ({ item }: { item: SosMsg }) => ( {}} /> ), // eslint-disable-next-line react-hooks/exhaustive-deps [], ); const listData: SosMsg[] = messages; function renderItem({ item }: { item: SosMsg }) { return {}} />; } return ( {/* Header */} Lyra SOS {(thinking || isLoading) && !isSpeaking && ( denkt nach … )} {!thinking && !isLoading && isTtsLoading && !isSpeaking && ( spricht... )} {isSpeaking && ( )} setSoundEnabled((s) => !s)} hitSlop={12}> {playingGame ? ( {playingGame === 'memory' && } {playingGame === 'tictactoe' && } {playingGame === 'snake' && } {playingGame === 'tetris' && } ) : ( {( item.id} contentContainerStyle={[st.listContent, { paddingTop: topBarHeight + 10 }]} showsVerticalScrollIndicator={false} onScroll={handleScroll} scrollEventThrottle={100} onContentSizeChange={() => { if (isNearBottomRef.current) flatRef.current?.scrollToEnd({ animated: false }); }} ListFooterComponent={null} /> {showScrollBtn && ( { flatRef.current?.scrollToEnd({ animated: true }); setShowScrollBtn(false); }}> )} )} {/* Chips above input — only after Lyra has answered. Bei Standard-Actions (breathing/game/overcome/etc): Ionicons (native = SF Symbols iOS, Material Android) + Label OHNE Emoji. Bei custom-Actions ohne Mapping: Emoji aus chip.label (LLM-generiert). Verhindert "Lung-Emoji + leaf-Icon"-Doppelung. */} {currentChips.length > 0 && !isLoading && !thinking && ( {currentChips.map((chip) => { const isOvercome = chip.action === 'overcome'; const isStats = chip.action === 'show_stats'; const isFeel = chip.action.startsWith('feel:'); const isBreathing = chip.action === 'breathing'; const isGame = chip.action === 'game_picker' || chip.action === 'just_play'; const isHelp = chip.action === 'need_help'; const isClose = chip.action === 'close'; const iconColor = isBreathing ? '#0891b2' : isGame ? '#9333ea' : isOvercome ? '#16a34a' : isStats ? '#2563eb' : isHelp ? '#dc2626' : isClose ? '#94a3b8' : isFeel ? '#7c3aed' : '#475569'; const iconName: any = isBreathing ? 'leaf-outline' : isGame ? 'game-controller-outline' : isOvercome ? 'checkmark-circle-outline' : isStats ? 'stats-chart-outline' : isHelp ? 'alert-circle-outline' : isClose ? 'close-circle-outline' : isFeel ? 'heart-outline' : null; // Wenn Ionicons-Match: Emoji aus Label strippen (kein Doppel-Icon) const labelText = iconName ? chip.label.replace(/^\s*[\p{Extended_Pictographic}\p{Emoji_Component}]+\s*/u, '') : chip.label; return ( handleChip(chip.action)} disabled={thinking} style={({ pressed }) => [ st.chip, pressed && st.chipPressed, thinking && { opacity: 0.4 }, ]} > {iconName && } {labelText} ); })} )} {/* Input bar — natürliche Höhe, außerhalb flex:1 */} 0 ? 8 : Math.max(12, insets.bottom) }]}> {input.trim() !== '' && ( )} )} {/* Breathing drawer — absolute, slides up over input */} {isBreathing && ( )} {/* Game picker drawer — absolute, slides up over input */} {isPickingGame && !playingGame && ( setIsPickingGame(false)} /> )} {/* Inline-Rating Drawer (Alternative zum Exit-Modal) */} {ratingDrawerVisible && ( setRatingDrawerVisible(false)} /> )} {/* Share-Success Drawer */} {shareDrawerVisible && ( setShareDrawerVisible(false)} onRegenerate={generateShareDraft} /> )} {/* Exit-Feedback-Modal */} ); } function makeStyles(colors: ReturnType) { return StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bg }, topBar: { position: 'absolute', left: 0, right: 0, zIndex: 10, flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', paddingHorizontal: 12 }, topBarBackdrop: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 9, backgroundColor: colors.bg }, actionBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: colors.surface, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 6, elevation: 4 }, avatarCenter: { flex: 1, alignItems: 'center', gap: 4 }, avatarMeta: { alignItems: 'center', gap: 2 }, avatarName: { fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }, speakingRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, stopBtn: { width: 18, height: 18, borderRadius: 9, backgroundColor: colors.surfaceElevated, alignItems: 'center', justifyContent: 'center' }, listContent: { paddingHorizontal: 12, paddingBottom: 4 }, scrollDownBtn: { position: 'absolute', bottom: 8, right: 16, width: 36, height: 36, borderRadius: 18, backgroundColor: '#374151', alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 4 }, chip: { borderRadius: 14, borderWidth: 1.5, borderColor: colors.border, backgroundColor: colors.bg, paddingHorizontal: 16, paddingVertical: 11, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.12, shadowRadius: 6, elevation: 3, }, chipPressed: { backgroundColor: colors.surfaceElevated, borderColor: colors.textMuted, transform: [{ scale: 0.97 }], shadowOpacity: 0.05, }, chipText: { fontFamily: 'Nunito_600SemiBold', fontSize: 14, color: colors.textMuted }, inputBar: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingTop: 8, borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.bg, gap: 8 }, textInput: { flex: 1, minHeight: 40, maxHeight: 120, backgroundColor: colors.surfaceElevated, borderRadius: 20, paddingHorizontal: 14, paddingVertical: 10, fontSize: 15, fontFamily: 'Nunito_400Regular', color: colors.text }, sendBtn: { width: 38, height: 38, borderRadius: 19, backgroundColor: '#007AFF', alignItems: 'center', justifyContent: 'center' }, }); }