chahinebrini dbc62b98ca perf(chat): index direct_messages + DB-side latest-per-partner query; remove unconditional Lyra welcome-back
- getDmConversations: DISTINCT ON (partner) ORDER BY partner, created_at DESC
  → one row per conversation in a single indexed query instead of fetching
  up to 500 rows and de-duplicating in JS
- add indexes on direct_messages (sender_id,created_at DESC),
  (receiver_id,created_at DESC), (receiver_id,read_at) — table had none, so
  every conversation-list load (runs per user on app launch for the badge)
  was a full-table scan + sort
- lyra.tsx: drop the welcome-back greeting that fired on every first coach
  open per session regardless of protection status/language (always German,
  unconditional). Endpoint kept for future conditional use

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:57:29 +02:00

938 lines
31 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 {
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 (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<ActivityIndicator size="large" color={colors.textMuted} />
</View>
);
}
// ── 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 (
<View style={styles.thinkingRow}>
{anim.map((a, i) => (
<Animated.View
key={i}
style={[
styles.thinkingDot,
{ backgroundColor: colors.border, transform: [{ translateY: a.interpolate({ inputRange: [0, 1], outputRange: [0, -5] }) }] },
]}
/>
))}
</View>
);
}
// ── 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 (
<View style={[styles.msgRow, isUser ? styles.msgRowUser : styles.msgRowAssistant]}>
<View style={[styles.bubbleCol, isUser ? styles.bubbleColUser : styles.bubbleColAssistant]}>
<View style={[styles.bubble, isUser ? styles.bubbleUser : [styles.bubbleAssistant, { backgroundColor: colors.surfaceElevated }]]}>
<Text style={[styles.bubbleText, isUser ? styles.bubbleTextUser : [styles.bubbleTextAssistant, { color: colors.text }]]}>
{item.content}
</Text>
</View>
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
{item.feedbackSaved && (
<>
<Ionicons name="checkmark-circle" size={11} color={colors.success} />
<Text style={[styles.feedbackText, { color: colors.success }]}>{t('coach.feedback_saved')}</Text>
</>
)}
<Text style={[styles.timestampText, { color: colors.textMuted }]}>{formatTimestamp(item.timestamp)}</Text>
</View>
</View>
</View>
);
}
// ── Main screen ───────────────────────────────────────────────────────────────
export default function CoachScreen() {
const { t, i18n } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const flatRef = useRef<FlatList>(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<Emotion>('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<Audio.Recording | null>(null);
const soundRef = useRef<Audio.Sound | null>(null);
const micHeld = useRef(false);
const recordingTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const recordingStartTime = useRef<number>(0);
const [audioLevel, setAudioLevel] = useState(0); // 01, live metering
const [trashFlash, setTrashFlash] = useState(false); // kurze rote Animation beim Cancel
const typingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const emotionTimer = useRef<ReturnType<typeof setTimeout> | 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<NativeScrollEvent>) {
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) {
setEmotion((e) => (e === 'thinking' ? e : 'happy'));
if (typingTimer.current) clearTimeout(typingTimer.current);
typingTimer.current = setTimeout(() => {
setEmotion((e) => (e === 'happy' ? 'idle' : e));
}, 2500);
} else {
if (typingTimer.current) clearTimeout(typingTimer.current);
setEmotion((e) => (e === 'happy' ? '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 01.
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);
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();
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('happy');
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 }) => <MessageRow item={item} t={t} />,
[t]
);
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.bg }]} edges={['top']}>
{/* Backdrop hinter topBar — verhindert dass Chat-Texte hinter dem Avatar */}
{/* sichtbar werden ("kollidieren mit Lyra schriftzug"). */}
<View
style={[styles.topBarBackdrop, { height: insets.top + 170, backgroundColor: colors.bg }]}
pointerEvents="none"
/>
{/* Floating header — no bar, avatar + 2 icon buttons hover over chat */}
<View style={[styles.topBar, { top: insets.top + 6 }]}>
<TouchableOpacity style={[styles.backBtn, { backgroundColor: colorScheme === 'dark' ? 'rgba(44,44,46,0.92)' : 'rgba(255,255,255,0.92)' }]} onPress={() => router.replace('/(app)' as never)} hitSlop={12} activeOpacity={0.7}>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.avatarCenter}>
<View pointerEvents="none">
<RiveAvatar emotion={emotion} size="md" />
</View>
<View style={styles.avatarMeta}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={[styles.avatarName, { color: colors.text }]}>{t('coach.lyra')}</Text>
<View
style={{
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 6,
backgroundColor: colors.brandOrange + '22',
borderWidth: 1,
borderColor: colors.brandOrange + '55',
}}
>
<Text
style={{
fontSize: 10,
fontFamily: 'Nunito_700Bold',
color: colors.brandOrange,
letterSpacing: 0.5,
}}
>
{t('coach.modeBadge.coach')}
</Text>
</View>
</View>
{isSpeaking && (
<View style={styles.speakingRow}>
<VoiceBars count={5} baseColor={colors.brandOrange} active={true} />
<Text style={[styles.speakingLabel, { color: colors.brandOrange }]}>{t('coach.speaking')}</Text>
<TouchableOpacity style={[styles.stopBtn, { backgroundColor: colors.surfaceElevated }]} onPress={stopSpeaking} hitSlop={6} activeOpacity={0.7}>
<Ionicons name="square" size={10} color={colors.brandOrange} />
</TouchableOpacity>
</View>
)}
</View>
</View>
<TouchableOpacity style={[styles.newChatBtn, { backgroundColor: colorScheme === 'dark' ? 'rgba(44,44,46,0.92)' : 'rgba(255,255,255,0.92)' }]} onPress={handleNewChat} hitSlop={12} activeOpacity={0.7}>
<Ionicons name="refresh-outline" size={22} color={colors.textMuted} />
</TouchableOpacity>
</View>
{/* Content area */}
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={0}
>
{isLoading ? (
<LoadingPulse />
) : (
<View style={{ flex: 1 }}>
<FlatList
ref={flatRef}
data={enrichedMessages}
renderItem={renderMessage}
keyExtractor={(m) => 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 ? (
<View style={styles.msgRowAssistant}>
<View style={[styles.bubbleAssistant, { backgroundColor: colors.surfaceElevated }]}>
<ThinkingDots />
</View>
</View>
) : null
}
/>
{/* Scroll-to-bottom button */}
{showScrollBtn && (
<TouchableOpacity style={styles.scrollDownBtn} onPress={scrollToBottom} activeOpacity={0.8}>
<Ionicons name="chevron-down" size={18} color="#fff" />
</TouchableOpacity>
)}
</View>
)}
{/* Input bar */}
<View style={[styles.inputBar, { paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg, borderTopColor: colors.border }]}>
{isRecording ? (
<VoiceRecordingBar
duration={recordingDuration}
level={audioLevel}
trashFlash={trashFlash}
onCancel={cancelRecording}
onSend={onMicUp}
sendIcon="arrow-up"
accentColor={colors.brandOrange}
/>
) : isTranscribing ? (
<View style={styles.transcribingRow}>
<Ionicons name="sync" size={16} color={colors.textMuted} />
<Text style={[styles.transcribingText, { color: colors.textMuted }]}>{t('coach.transcribing')}</Text>
</View>
) : (
<TextInput
style={[styles.textInput, { backgroundColor: colors.surfaceElevated, color: colors.text }]}
placeholder={t('coach.placeholder')}
placeholderTextColor={colors.textMuted}
value={input}
onChangeText={handleInputChange}
multiline
maxLength={1000}
returnKeyType="send"
onSubmitEditing={handleSend}
/>
)}
{!isRecording && !isTranscribing && (
<TouchableOpacity
style={[styles.micBtn, { backgroundColor: colors.surfaceElevated }, thinking && styles.micBtnDisabled]}
onPressIn={onMicDown}
onPressOut={onMicUp}
disabled={thinking}
activeOpacity={0.7}
>
<Ionicons name="mic" size={18} color={colors.textMuted} />
</TouchableOpacity>
)}
{!isRecording && !isTranscribing && input.trim() !== '' && (
<TouchableOpacity
style={[styles.sendBtn, thinking && styles.sendBtnDisabled]}
onPress={handleSend}
disabled={thinking || !input.trim()}
activeOpacity={0.7}
>
<Ionicons name="send" size={16} color="#fff" />
</TouchableOpacity>
)}
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
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',
},
});