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',
},
});