chahinebrini 96e1b8368c feat(lyra): deterministisches Krisen-Sicherheitsnetz (R-LYRA-01)
LLM-unabhaengiges Sicherheitsnetz fuer Lyras SOS-Pfad, schliesst das
Top-Risiko der Risiko-Akte (verpasste Krise, ISO 14971 R-LYRA-01).

Backend:
- crisis-filter.ts: deterministische Krisen-/Suizid-Erkennung (DE primaer,
  EN/FR/AR Grundabdeckung) auf den letzten User-Nachrichten, synchron, kein LLM
- sos-session.post: liefert crisisLevel sofort an die App (vor Stream-Start)
- sos-stream: sendet bei Krise zuerst 'crisis_chips' (BZgA/112/Telefonseelsorge);
  Fallback an 3 Stellen (LLM-Fehler/Abbruch/keine Chips) -> nie leerer Screen
- 43/43 Unit-Tests (crisis.json positiv, harmless.json False-Positive-Guard)

Frontend (urge.tsx):
- permanente rote Krisen-Bar oben, durch LLM-Chips nicht ueberschreibbar
  (eigener State-Slot), Hotline-Chips als tel:-Links
- neue Locale-Strings DE/EN

Risiko-Akte: R-LYRA-01 Restrisiko HOCH -> MITTEL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:56:34 +02:00

1405 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef, useState } from 'react';
import {
View, Text, TextInput, FlatList, Pressable, Platform, Animated,
Keyboard, KeyboardAvoidingView, StyleSheet, NativeSyntheticEvent,
NativeScrollEvent, ActivityIndicator, AppState, TouchableOpacity, Linking,
} 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, type CrisisLevel } 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<FlatList>(null);
const [messages, setMessages] = useState<SosMsg[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isBreathing, setIsBreathing] = useState(false);
const [input, setInput] = useState('');
const [thinking, setThinking] = useState(false);
const [emotion, setEmotion] = useState<LyraEmotion>('idle');
const [isSpeaking, setIsSpeaking] = useState(false);
const [isTtsLoading, setIsTtsLoading] = useState(false);
const [soundEnabled, setSoundEnabled] = useState(true);
const soundEnabledRef = useRef(true);
const [chipSet, setChipSet] = useState<ChipSet>('start');
const [dynamicChips, setDynamicChips] = useState<ChipSpec[]>([]);
const [crisisChips, setCrisisChips] = useState<Array<{ label: string; action: string }>>([]);
const [crisisLevel, setCrisisLevel] = useState<CrisisLevel>('none');
const [userTurnCount, setUserTurnCount] = useState(0);
// ——— Session-Tracking für DiGA ———
const sessionStartRef = useRef<Date>(new Date());
const breathingCountRef = useRef(0);
const gamesPlayedRef = useRef<Array<{ game: string; score: number; durationSec: number }>>([]);
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<number>(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<SosFeedback | null>(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<GameType | null>(null);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [showScrollBtn, setShowScrollBtn] = useState(false);
const ttsRef = useRef<Audio.Sound | null>(null);
const ttsAbortRef = useRef<AbortController | null>(null);
// Phase B: sentence-level streaming TTS — eine Queue pro sendToLyra-Call.
const ttsQueueRef = useRef<SosTtsQueue | null>(null);
const emotionTimer = useRef<ReturnType<typeof setTimeout> | 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<Map<string, string>>(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<NativeScrollEvent>) {
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<void> {
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<void>((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<void> {
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<void>((resolve) => {
let cancel: (() => void) | undefined;
streamSosLyra({
apiBase,
token: session.access_token,
messages: apiMessages,
locale: i18n.language,
llmProvider: currentLlmProvider(),
onMetric: bench.mark,
onCrisisLevel: (level) => setCrisisLevel(level),
onCrisisChips: (chips) => setCrisisChips(chips),
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<void>((resolve) => {
streamSosLyra({
apiBase,
token: session.access_token,
messages: SOS_BOOT,
locale: i18n.language,
llmProvider: currentLlmProvider(),
onMetric: greetingBench.mark,
onCrisisLevel: (level) => { if (!cancelled) setCrisisLevel(level); },
onCrisisChips: (chips) => { if (!cancelled) setCrisisChips(chips); },
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();
if (action.startsWith('tel:')) {
Linking.openURL(action).catch(() => {});
return;
}
// 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<GameType, string> = { 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":"<post-text>","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 }) => (
<MessageRow
item={item}
onGameSelect={handleGameSelect}
onBreathingDone={() => {}}
/>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const listData: SosMsg[] = messages;
function renderItem({ item }: { item: SosMsg }) {
return <MessageRow item={item} onGameSelect={handleGameSelect} onBreathingDone={() => {}} />;
}
return (
<SafeAreaView style={st.container} edges={['top']}>
<View style={[st.topBarBackdrop, { height: topBarHeight }]} pointerEvents="none" />
{/* Header */}
<View style={[st.topBar, { top: insets.top + 6 }]}>
<Pressable style={st.actionBtn} onPress={attemptExit} hitSlop={12}>
<Ionicons name="close" size={22} color={colors.textMuted} />
</Pressable>
<View style={st.avatarCenter}>
<RiveAvatar emotion={emotion} size="md" />
<View style={st.avatarMeta}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={st.avatarName}>Lyra</Text>
<View
style={{
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 6,
backgroundColor: colors.error + '22',
borderWidth: 1,
borderColor: colors.error + '55',
}}
>
<Text
style={{
fontSize: 10,
fontFamily: 'Nunito_700Bold',
color: colors.error,
letterSpacing: 0.5,
}}
>
SOS
</Text>
</View>
</View>
{(thinking || isLoading) && !isSpeaking && (
<View style={st.speakingRow}>
<VoiceBars count={5} baseColor="#3b82f6" />
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#3b82f6' }}>denkt nach </Text>
</View>
)}
{!thinking && !isLoading && isTtsLoading && !isSpeaking && (
<View style={st.speakingRow}>
<ActivityIndicator size="small" color={colors.brandOrange} />
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#9ca3af' }}>spricht...</Text>
</View>
)}
{isSpeaking && (
<View style={st.speakingRow}>
<VoiceBars count={5} baseColor={colors.brandOrange} />
<Pressable style={st.stopBtn} onPress={stopSpeaking} hitSlop={6}>
<Ionicons name="square" size={10} color={colors.brandOrange} />
</Pressable>
</View>
)}
</View>
</View>
<Pressable style={st.actionBtn} onPress={() => setSoundEnabled((s) => !s)} hitSlop={12}>
<Ionicons name={soundEnabled ? 'volume-high' : 'volume-mute-outline'} size={22} color={soundEnabled ? colors.brandOrange : '#9ca3af'} />
</Pressable>
</View>
{playingGame ? (
<View style={{ flex: 1 }}>
<GameHeader game={playingGame} emotion={emotion} onBack={handleGameAbandon} />
<View style={{ flex: 1, padding: 14 }}>
{playingGame === 'memory' && <MemoryGame mode="sos" onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
{playingGame === 'tictactoe' && <TicTacToeGame mode="sos" onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
{playingGame === 'snake' && <SnakeGame mode="sos" onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
{playingGame === 'tetris' && <TetrisGame mode="sos" onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
</View>
</View>
) : (
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'} keyboardVerticalOffset={0}>
{(
<View style={{ flex: 1 }}>
<FlatList
ref={flatRef}
data={listData}
renderItem={renderItem as any}
keyExtractor={(item: any) => 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 && (
<Pressable style={st.scrollDownBtn} onPress={() => { flatRef.current?.scrollToEnd({ animated: true }); setShowScrollBtn(false); }}>
<Ionicons name="chevron-down" size={18} color="#fff" />
</Pressable>
)}
</View>
)}
{/* Crisis chips — permanent row, shown as soon as backend detects a crisis.
Never replaced by LLM chips. Tel-actions open the dialer directly. */}
{crisisChips.length > 0 && (
<View style={st.crisisBar}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<Ionicons name="warning-outline" size={14} color="#dc2626" />
<Text style={st.crisisBarLabel}>{t('coach.crisis_bar_label')}</Text>
</View>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
{crisisChips.map((chip) => (
<TouchableOpacity
key={chip.action}
activeOpacity={0.7}
onPress={() => handleChip(chip.action)}
style={st.crisisChip}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name="call-outline" size={14} color="#dc2626" />
<Text style={st.crisisChipText}>{chip.label}</Text>
</View>
</TouchableOpacity>
))}
</View>
</View>
)}
{/* 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 && (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 12, paddingVertical: 8, gap: 8 }}>
{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 (
<Pressable
key={chip.action}
onPress={() => handleChip(chip.action)}
disabled={thinking}
style={[st.chip, thinking && { opacity: 0.4 }]}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
{iconName && <Ionicons name={iconName} size={15} color={iconColor} />}
<Text style={st.chipText}>{labelText}</Text>
</View>
</Pressable>
);
})}
</View>
)}
{/* Input bar — natürliche Höhe, außerhalb flex:1 */}
<View style={[st.inputBar, { paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom) }]}>
<TextInput
style={st.textInput}
placeholder={t('coach.placeholder')}
placeholderTextColor="#a3a3a3"
value={input}
onChangeText={setInput}
multiline
maxLength={1000}
returnKeyType="send"
onSubmitEditing={handleSend}
editable={!thinking}
/>
{input.trim() !== '' && (
<Pressable style={[st.sendBtn, thinking && { opacity: 0.4 }]} onPress={handleSend} disabled={thinking || !input.trim()}>
<Ionicons name="send" size={16} color="#fff" />
</Pressable>
)}
</View>
</KeyboardAvoidingView>
)}
{/* Breathing drawer — absolute, slides up over input */}
{isBreathing && (
<BreathingDrawer
onDone={handleBreathingDone}
onSpeak={speakText}
/>
)}
{/* Game picker drawer — absolute, slides up over input */}
{isPickingGame && !playingGame && (
<GamePickerDrawer
onSelect={handleGameSelect}
onClose={() => setIsPickingGame(false)}
/>
)}
{/* Inline-Rating Drawer (Alternative zum Exit-Modal) */}
{ratingDrawerVisible && (
<InlineRatingDrawer
onSubmit={submitInlineRating}
onClose={() => setRatingDrawerVisible(false)}
/>
)}
{/* Share-Success Drawer */}
{shareDrawerVisible && (
<ShareSuccessDrawer
initialText={shareDraft}
generating={shareGenerating}
onShare={submitSharePost}
onClose={() => setShareDrawerVisible(false)}
onRegenerate={generateShareDraft}
/>
)}
{/* Exit-Feedback-Modal */}
<SosFeedbackModal
visible={feedbackVisible}
onSubmit={handleFeedbackSubmit}
onSkip={handleFeedbackSkip}
/>
</SafeAreaView>
);
}
function makeStyles(colors: ReturnType<typeof useColors>) {
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' },
crisisBar: {
marginHorizontal: 12,
marginBottom: 4,
marginTop: 8,
backgroundColor: '#fef2f2',
borderRadius: 12,
borderWidth: 1,
borderColor: '#fca5a5',
paddingHorizontal: 12,
paddingVertical: 10,
},
crisisBarLabel: { fontSize: 11, fontFamily: 'Nunito_700Bold', color: '#dc2626', textTransform: 'uppercase', letterSpacing: 0.5 },
crisisChip: {
borderRadius: 12,
borderWidth: 1.5,
borderColor: '#fca5a5',
backgroundColor: '#fff1f2',
paddingHorizontal: 12,
paddingVertical: 8,
},
crisisChipText: { fontFamily: 'Nunito_600SemiBold', fontSize: 13, color: '#dc2626' },
});
}