import { useRef, useState, useEffect, useCallback, } from 'react'; import { View, Text, TextInput, FlatList, TouchableOpacity, Platform, Animated, Keyboard, KeyboardAvoidingView, StyleSheet, ActivityIndicator, NativeSyntheticEvent, NativeScrollEvent, } 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 } 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, type Emotion } from '../components/RiveAvatar'; import { useCoachStore, type Message } from '../stores/coach'; import { apiFetch } from '../lib/api'; import { supabase } from '../lib/supabase'; import { useColors } from '../lib/theme'; import { useThemeStore } from '../stores/theme'; import { detectEmotion } from '../lib/lyraResponse'; import { VoiceRecordingBar, VoiceBars, formatVoiceDuration } from '../components/chat/VoiceRecordingBar'; function formatTimestamp(date: Date): string { return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } // ── Loading skeleton ────────────────────────────────────────────────────────── function LoadingPulse() { const colors = useColors(); return ( ); } // ── Thinking dots ───────────────────────────────────────────────────────────── function ThinkingDots() { const colors = useColors(); const anim = useRef([new Animated.Value(0), new Animated.Value(0), new Animated.Value(0)]).current; useEffect(() => { const animations = anim.map((a, i) => Animated.loop( Animated.sequence([ Animated.delay(i * 160), Animated.timing(a, { toValue: 1, duration: 300, useNativeDriver: true }), Animated.timing(a, { toValue: 0, duration: 300, useNativeDriver: true }), ]) ) ); animations.forEach((a) => a.start()); return () => animations.forEach((a) => a.stop()); }, []); return ( {anim.map((a, i) => ( ))} ); } // ── Message row (Insta-DM style) ────────────────────────────────────────────── type MessageWithMeta = Message & { timestamp: Date }; function MessageRow({ item, t, }: { item: MessageWithMeta; t: (key: string) => string; }) { const colors = useColors(); const isUser = item.role === 'user'; return ( {item.content} {item.feedbackSaved && ( <> {t('coach.feedback_saved')} )} {formatTimestamp(item.timestamp)} ); } // ── Main screen ─────────────────────────────────────────────────────────────── export default function CoachScreen() { const { t, i18n } = useTranslation(); const router = useRouter(); const insets = useSafeAreaInsets(); const flatRef = useRef(null); const colors = useColors(); const colorScheme = useThemeStore((s) => s.colorScheme); // Reaktive Slices — nur was UI-relevant ist (Re-Render bei diesen). const messages = useCoachStore((s) => s.messages); const thinking = useCoachStore((s) => s.thinking); // Actions sind stable — keine Re-Renders bei Bezug. const loadHistory = useCoachStore((s) => s.loadHistory); const clearHistory = useCoachStore((s) => s.clearHistory); const sendMessage = useCoachStore((s) => s.sendMessage); const pushMessage = useCoachStore((s) => s.pushMessage); const markFeedbackSaved = useCoachStore((s) => s.markFeedbackSaved); const setThinking = useCoachStore((s) => s.setThinking); const [input, setInput] = useState(''); const [emotion, setEmotion] = useState('idle'); const [isSpeaking, setIsSpeaking] = useState(false); const [isRecording, setIsRecording] = useState(false); const [isTranscribing, setIsTranscribing] = useState(false); const [recordingDuration, setRecordingDuration] = useState(0); const [showScrollBtn, setShowScrollBtn] = useState(false); const [isLoading, setIsLoading] = useState(false); const [keyboardHeight, setKeyboardHeight] = useState(0); const recordingRef = useRef(null); const soundRef = useRef(null); const micHeld = useRef(false); const recordingTimer = useRef | null>(null); const recordingStartTime = useRef(0); const [audioLevel, setAudioLevel] = useState(0); // 0–1, live metering const [trashFlash, setTrashFlash] = useState(false); // kurze rote Animation beim Cancel const typingTimer = useRef | null>(null); const emotionTimer = useRef | null>(null); const isNearBottomRef = useRef(true); // Load history. Store-cached (historyLoaded) → kein Re-Fetch + kein // Spinner-Blink bei Tab-Wechsel. // NOTE: Welcome-Back-Begrüßung (/api/lyra/welcome-back) wurde entfernt — sie // erschien bedingungslos bei jedem ersten Coach-Open der Session (immer Deutsch, // unabhängig von Schutz-Status/Sprache). Re-Enable später nur conditional. useEffect(() => { let cancelled = false; // Aktuelle Werte aus dem Store lesen (statt Closure-Stale beim ersten Render). const snap = useCoachStore.getState(); const needsHistory = !snap.historyLoaded; if (!needsHistory) { // Coach war diese Session schon offen → instant rendern, kein Spinner. requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: false })); return; } async function init() { setIsLoading(true); try { await loadHistory(); } catch { // non-fatal } if (cancelled) return; setIsLoading(false); requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: false })); } init(); return () => { cancelled = true; }; }, []); useEffect(() => { // iOS: Will*-Events feuern VOR der Keyboard-Animation → paddingBottom // ändert sich synchron mit KeyboardAvoidingView. Sonst springt der // Input erst hoch, dann nach unten ("Zucken"). // Android: Will*-Events feuern unzuverlässig → Did* ist der stabile Pfad. 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(); }; }, []); // Enrich messages with timestamp const enrichedMessages: MessageWithMeta[] = messages.map((msg) => ({ ...msg, timestamp: new Date(), })); // Scroll to bottom when messages change (only when near bottom) useEffect(() => { if (messages.length === 0) return; if (isNearBottomRef.current) { requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true })); } else { setShowScrollBtn(true); } }, [messages.length, thinking]); function handleScroll(e: NativeSyntheticEvent) { const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent; const distFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height; isNearBottomRef.current = distFromBottom < 80; if (isNearBottomRef.current) setShowScrollBtn(false); } function scrollToBottom() { flatRef.current?.scrollToEnd({ animated: true }); setShowScrollBtn(false); } function handleInputChange(text: string) { setInput(text); if (text.length > 0) { // Lyra hört aufmerksam zu, während getippt wird (nicht während sie denkt). setEmotion((e) => (e === 'thinking' ? e : 'listening')); if (typingTimer.current) clearTimeout(typingTimer.current); typingTimer.current = setTimeout(() => { setEmotion((e) => (e === 'listening' ? 'idle' : e)); }, 2500); } else { if (typingTimer.current) clearTimeout(typingTimer.current); setEmotion((e) => (e === 'listening' ? 'idle' : e)); } } function scheduleEmotionReset(delay = 4000) { if (emotionTimer.current) clearTimeout(emotionTimer.current); emotionTimer.current = setTimeout(() => setEmotion('idle'), delay); } async function handleSend() { const content = input.trim(); if (!content || thinking) return; const userMsgId = Date.now().toString(); pushMessage({ id: userMsgId, role: 'user', content }); setInput(''); setThinking(true); setEmotion('thinking'); try { const res = await sendMessage(content, i18n.language); if (res.feedbackSaved) markFeedbackSaved(userMsgId); pushMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: res.message }); const e = detectEmotion(res.message); setEmotion(e); scheduleEmotionReset(e === 'empathy' ? 6000 : 4000); } catch { pushMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: t('coach.error'), isError: true }); setEmotion('idle'); } finally { setThinking(false); } } async function handleVoiceSend(text: string) { if (!text.trim() || thinking) return; const userMsgId = Date.now().toString(); pushMessage({ id: userMsgId, role: 'user', content: text }); setThinking(true); setEmotion('thinking'); try { const res = await sendMessage(text, i18n.language); if (res.feedbackSaved) markFeedbackSaved(userMsgId); pushMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: res.message }); const e = detectEmotion(res.message); setEmotion(e); try { const apiBase = Constants.expoConfig?.extra?.apiUrl as string; const session = (await supabase.auth.getSession()).data.session; const speakUrl = `${apiBase}/api/coach/speak`; console.log('[tts] POST', speakUrl, 'text-len:', res.message.length); const ttsRes = await fetch(speakUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}), }, body: JSON.stringify({ text: res.message, mode: 'chat' }), }); console.log('[tts] response status:', ttsRes.status, 'content-type:', ttsRes.headers.get('content-type')); if (ttsRes.ok) { // Backend streamt audio/mpeg direkt — als ArrayBuffer holen, base64-encoden, in tmp-File schreiben. const buffer = await ttsRes.arrayBuffer(); if (buffer.byteLength === 0) { console.warn('[tts] empty audio buffer'); } else { const bytes = new Uint8Array(buffer); // Chunked base64-Encoding (RN btoa kann mit großen Strings überlaufen) let binary = ''; const chunkSize = 0x8000; for (let i = 0; i < bytes.length; i += chunkSize) { binary += String.fromCharCode( ...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)) ); } // eslint-disable-next-line no-undef const base64 = global.btoa ? global.btoa(binary) : Buffer.from(binary, 'binary').toString('base64'); const tmpPath = `${FileSystem.cacheDirectory}lyra-tts-${Date.now()}.mp3`; await FileSystem.writeAsStringAsync(tmpPath, base64, { encoding: FileSystem.EncodingType.Base64, }); const { sound } = await Audio.Sound.createAsync({ uri: tmpPath }); soundRef.current = sound; setIsSpeaking(true); sound.setOnPlaybackStatusUpdate((status) => { if (status.isLoaded && status.didJustFinish) { setIsSpeaking(false); scheduleEmotionReset(0); sound.unloadAsync(); } }); await sound.playAsync(); } } else { console.warn('[tts] backend error:', ttsRes.status, await ttsRes.text()); } } catch (err) { console.warn('[tts] exception:', err); scheduleEmotionReset(e === 'empathy' ? 6000 : 4000); } } catch { pushMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: t('coach.error'), isError: true }); setEmotion('idle'); } finally { setThinking(false); } } function stopSpeaking() { soundRef.current?.stopAsync(); soundRef.current?.unloadAsync(); soundRef.current = null; setIsSpeaking(false); setEmotion('idle'); } function startRecordingTimer() { setRecordingDuration(0); setAudioLevel(0); recordingStartTime.current = Date.now(); recordingTimer.current = setInterval(async () => { setRecordingDuration(Math.floor((Date.now() - recordingStartTime.current) / 1000)); // Metering: Audio-Pegel für Stille/Sprechen-Unterscheidung try { const status = await recordingRef.current?.getStatusAsync(); if (status?.isRecording && status.metering !== undefined) { // metering ist in dBFS (-160 bis 0). Normalisieren auf 0–1. const normalized = Math.max(0, Math.min(1, (status.metering + 60) / 60)); setAudioLevel(normalized); } } catch { /* kein Metering auf diesem Gerät */ } }, 200); } function stopRecordingTimer() { if (recordingTimer.current) clearInterval(recordingTimer.current); recordingTimer.current = null; setRecordingDuration(0); setAudioLevel(0); } async function onMicDown() { // micHeld guard verhindert Doppel-Starts — aber wenn ein vorheriger Fehler // micHeld.current = true hinterlassen hat ohne isRecording zu setzen, // wäre der Mic dauerhaft blockiert. Reset wenn State inkonsistent. if (micHeld.current && !isRecording) micHeld.current = false; if (thinking || isTranscribing || isRecording || micHeld.current) return; if (isSpeaking) stopSpeaking(); const { status } = await Audio.requestPermissionsAsync(); if (status !== 'granted') return; micHeld.current = true; try { await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true }); const rec = new Audio.Recording(); await rec.prepareToRecordAsync({ ...Audio.RecordingOptionsPresets.HIGH_QUALITY, isMeteringEnabled: true, }); await rec.startAsync(); recordingRef.current = rec; setIsRecording(true); setEmotion('listening'); // Lyra hört zu, während aufgenommen wird startRecordingTimer(); } catch { micHeld.current = false; recordingRef.current = null; await Audio.setAudioModeAsync({ allowsRecordingIOS: false }).catch(() => {}); } } async function cancelRecording() { if (!isRecording) return; const rec = recordingRef.current; recordingRef.current = null; micHeld.current = false; stopRecordingTimer(); setEmotion('idle'); // Aufnahme verworfen → zurück in den Ruhezustand setTrashFlash(true); setTimeout(() => { setTrashFlash(false); setIsRecording(false); }, 350); try { await rec?.stopAndUnloadAsync(); } catch {} await Audio.setAudioModeAsync({ allowsRecordingIOS: false }); } async function onMicUp() { if (!micHeld.current || !isRecording) return; micHeld.current = false; stopRecordingTimer(); setIsRecording(false); const rec = recordingRef.current; recordingRef.current = null; if (!rec) return; try { await rec.stopAndUnloadAsync(); } catch { /* already stopped */ } await Audio.setAudioModeAsync({ allowsRecordingIOS: false }); const uri = rec.getURI(); console.log('[voice] URI after stop:', uri); if (!uri) return; setIsTranscribing(true); setEmotion('listening'); // verarbeitet die Stimme weiter (vor dem "thinking" in handleVoiceSend) try { // Backend erwartet base64-Audio in JSON-Body (NICHT FormData): // { audio: base64String, mimeType: 'audio/m4a', language: 'de' } const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64, }); const session = (await supabase.auth.getSession()).data.session; const apiUrl = Constants.expoConfig?.extra?.apiUrl as string; console.log('[voice] POST', `${apiUrl}/api/coach/transcribe`, 'language:', i18n.language, 'base64-bytes:', base64.length); const res = await fetch(`${apiUrl}/api/coach/transcribe`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}), }, body: JSON.stringify({ audio: base64, mimeType: 'audio/m4a', language: i18n.language, }), }); const json = await res.json(); console.log('[voice] transcribe response:', JSON.stringify(json).slice(0, 200)); const text: string = json?.data?.text ?? json?.text ?? ''; console.log('[voice] extracted text:', JSON.stringify(text)); if (text.trim()) { await handleVoiceSend(text); } } catch (err) { console.warn('[voice] transcribe error:', err); } finally { setIsTranscribing(false); setEmotion((e) => (e === 'happy' ? 'idle' : e)); } } const handleNewChat = useCallback(async () => { await clearHistory(); setEmotion('idle'); }, [clearHistory]); const renderMessage = useCallback( ({ item }: { item: MessageWithMeta }) => , [t] ); return ( {/* Backdrop hinter topBar — verhindert dass Chat-Texte hinter dem Avatar */} {/* sichtbar werden ("kollidieren mit Lyra schriftzug"). */} {/* Floating header — no bar, avatar + 2 icon buttons hover over chat */} router.replace('/(app)' as never)} hitSlop={12} activeOpacity={0.7}> {t('coach.lyra')} {t('coach.modeBadge.coach')} {isSpeaking && ( {t('coach.speaking')} )} {/* Content area */} {isLoading ? ( ) : ( m.id} contentContainerStyle={[styles.listContent, { paddingTop: insets.top + 180 }]} showsVerticalScrollIndicator={false} onScroll={handleScroll} scrollEventThrottle={100} onContentSizeChange={() => { if (isNearBottomRef.current) { flatRef.current?.scrollToEnd({ animated: false }); } }} ListFooterComponent={ thinking ? ( ) : null } /> {/* Scroll-to-bottom button */} {showScrollBtn && ( )} )} {/* Input bar */} 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg, borderTopColor: colors.border }]}> {isRecording ? ( ) : isTranscribing ? ( {t('coach.transcribing')} ) : ( )} {!isRecording && !isTranscribing && ( )} {!isRecording && !isTranscribing && input.trim() !== '' && ( )} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#ffffff', }, topBar: { // Floating-Overlay: schwebt über Chat, kein eigener Header-Block // top wird inline gesetzt (insets.top + 6) damit Avatar UNTER der Notch sitzt position: 'absolute', left: 0, right: 0, zIndex: 10, flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', paddingHorizontal: 12, pointerEvents: 'box-none', }, topBarBackdrop: { // Solider Hintergrund unter dem Floating-Avatar — Chat-Messages // scrollen darunter durch, werden aber nicht mehr sichtbar mit Lyra // Avatar/Name kollidieren. position: 'absolute', top: 0, left: 0, right: 0, zIndex: 9, backgroundColor: '#ffffff', }, backBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.92)', 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: 15, fontFamily: 'Nunito_700Bold', color: '#0a0a0a', }, statusLabel: { fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#737373', }, speakingRow: { flexDirection: 'row', alignItems: 'center', gap: 6, }, speakingLabel: { fontSize: 11, fontFamily: 'Nunito_600SemiBold', }, stopBtn: { width: 18, height: 18, borderRadius: 9, backgroundColor: '#f5f5f5', alignItems: 'center', justifyContent: 'center', }, newChatBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.92)', alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 6, elevation: 4, }, listContent: { paddingHorizontal: 12, // Reserve Platz für den Floating-Avatar oben (Avatar 112px + Name + Status + Gaps + Margin) paddingTop: 210, paddingBottom: 12, }, // Insta-DM row msgRow: { flexDirection: 'row', marginBottom: 4, alignItems: 'flex-end', }, msgRowUser: { justifyContent: 'flex-end', }, msgRowAssistant: { justifyContent: 'flex-start', }, assistantAvatarSlot: { width: 28, marginRight: 6, alignItems: 'center', justifyContent: 'flex-end', }, bubbleCol: { maxWidth: '75%', gap: 2, }, bubbleColUser: { alignItems: 'flex-end', }, bubbleColAssistant: { alignItems: 'flex-start', }, bubble: { borderRadius: 20, paddingHorizontal: 14, paddingVertical: 9, }, bubbleUser: { backgroundColor: '#007AFF', borderBottomRightRadius: 4, }, bubbleAssistant: { backgroundColor: '#f0f0f0', borderBottomLeftRadius: 4, }, bubbleText: { fontSize: 15, lineHeight: 21, fontFamily: 'Nunito_400Regular', }, bubbleTextUser: { color: '#ffffff', }, bubbleTextAssistant: { color: '#0a0a0a', }, metaRow: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingHorizontal: 4, marginBottom: 4, }, metaRowUser: { justifyContent: 'flex-end', }, metaRowAssistant: { justifyContent: 'flex-start', }, feedbackText: { fontSize: 10, color: '#16a34a', fontFamily: 'Nunito_400Regular', }, timestampText: { fontSize: 10, color: '#a3a3a3', fontFamily: 'Nunito_400Regular', }, thinkingRow: { flexDirection: 'row', gap: 4, alignItems: 'center', paddingVertical: 4, }, thinkingDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: '#d4d4d4', }, scrollDownBtn: { position: 'absolute', bottom: 12, right: 16, width: 36, height: 36, borderRadius: 18, backgroundColor: '#007AFF', alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 4, elevation: 4, }, inputBar: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, paddingHorizontal: 12, paddingTop: 8, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#e5e5e5', backgroundColor: 'rgba(255,255,255,0.97)', }, textInput: { flex: 1, backgroundColor: '#f5f5f5', borderRadius: 22, paddingVertical: 9, paddingHorizontal: 16, fontSize: 15, fontFamily: 'Nunito_400Regular', color: '#0a0a0a', maxHeight: 120, }, micBtn: { width: 44, height: 44, borderRadius: 22, backgroundColor: '#f5f5f5', alignItems: 'center', justifyContent: 'center', }, micBtnActive: { backgroundColor: '#dc2626', transform: [{ scale: 1.1 }], }, micBtnDisabled: { opacity: 0.4, }, sendBtn: { width: 38, height: 38, borderRadius: 19, backgroundColor: '#007AFF', alignItems: 'center', justifyContent: 'center', }, sendBtnDisabled: { opacity: 0.4, }, transcribingRow: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingVertical: 10, }, transcribingText: { fontSize: 14, color: '#737373', fontFamily: 'Nunito_400Regular', }, });