chahinebrini 59a80627d8 chore(deps): Expo SDK 54 / RN 0.81 — Phase 1 core upgrade (JS-side)
Versions:
- expo: 53.0.0 → 54.0.34
- react-native: 0.79.6 → 0.81.5
- react: 19.0.0 → 19.1.0
- expo-router: 5.1.11 → 6.0.23 (major)
- react-native-reanimated: 4.0.0 → 4.1.7
- react-native-worklets: 0.4.0 → 0.5.1
- react-native-screens: 4.11.1 → 4.16.0
- react-native-gesture-handler: 2.24.0 → 2.28.0
- @expo/metro-runtime: 5.0.5 → 6.1.2
- @types/react: → 19.2.14
- expo-av: 15.1.7 → 16.0.8 (still deprecated, last shipping in SDK 54)

expo-file-system breaking change quick-fix:
- New SDK 54 API is class-based (File/Directory/Paths). Legacy API `cacheDirectory`
  + `EncodingType` moved to `expo-file-system/legacy` sub-export.
- 6 files updated to import from `expo-file-system/legacy` with TODO(sdk54)
  marker. Proper migration tracked as Task #14.

Smoke-test: 0 TS errors, Metro bundles 2185 modules in 5.9s.

Native binary still SDK 53 — Phase 5 prebuild --clean pending.
Branch: upgrade/sdk-54, rollback tag: pre-sdk54-upgrade

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:46:09 +02:00

978 lines
31 KiB
TypeScript

import {
useRef,
useState,
useEffect,
useCallback,
} from 'react';
import {
View,
Text,
TextInput,
FlatList,
Pressable,
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 { colors } from '../lib/theme';
const EMPATHY_RE = /schwer|rückfall|traurig|schlimm|hoffnungslos|hilf|verloren|scham|schuld|verzweifelt/i;
const HAPPY_RE = /toll|super|geschafft|stark|glückwunsch|stolz|fantastisch|weiter so|prima|gut gemacht/i;
function detectEmotion(text: string): Emotion {
if (HAPPY_RE.test(text)) return 'happy';
if (EMPATHY_RE.test(text)) return 'empathy';
return 'idle';
}
function formatDuration(s: number): string {
const m = Math.floor(s / 60);
const sec = (s % 60).toString().padStart(2, '0');
return `${m}:${sec}`;
}
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// ── Loading skeleton ──────────────────────────────────────────────────────────
// Standard-Spinner — kein zweiter Rive-Avatar (der ist bereits im topBar oben).
function LoadingPulse() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<ActivityIndicator size="large" color={colors.textMuted} />
</View>
);
}
// ── Thinking dots ─────────────────────────────────────────────────────────────
function ThinkingDots() {
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,
{ transform: [{ translateY: a.interpolate({ inputRange: [0, 1], outputRange: [0, -5] }) }] },
]}
/>
))}
</View>
);
}
// ── Voice bars ────────────────────────────────────────────────────────────────
function VoiceBars({ count, baseColor }: { count: number; baseColor: string }) {
const anims = useRef(Array.from({ length: count }, () => new Animated.Value(4))).current;
useEffect(() => {
const animations = anims.map((a, i) =>
Animated.loop(
Animated.sequence([
Animated.timing(a, { toValue: 4 + Math.random() * 14, duration: 450 + (i % 5) * 80, useNativeDriver: false }),
Animated.timing(a, { toValue: 4, duration: 450 + (i % 5) * 80, useNativeDriver: false }),
])
)
);
animations.forEach((a) => a.start());
return () => animations.forEach((a) => a.stop());
}, []);
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 2, height: 20 }}>
{anims.map((a, i) => (
<Animated.View
key={i}
style={{ width: 2.5, height: a, borderRadius: 2, backgroundColor: baseColor, opacity: 0.75 }}
/>
))}
</View>
);
}
// ── Message row (Insta-DM style) ──────────────────────────────────────────────
type MessageWithMeta = Message & { timestamp: Date };
function MessageRow({
item,
t,
}: {
item: MessageWithMeta;
t: (key: string) => string;
}) {
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]}>
<Text style={[styles.bubbleText, isUser ? styles.bubbleTextUser : styles.bubbleTextAssistant]}>
{item.content}
</Text>
</View>
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
{item.feedbackSaved && (
<>
<Ionicons name="checkmark-circle" size={11} color="#16a34a" />
<Text style={styles.feedbackText}>{t('coach.feedback_saved')}</Text>
</>
)}
<Text style={styles.timestampText}>{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);
// 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 setWelcomeBackShown = useCoachStore((s) => s.setWelcomeBackShown);
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 typingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const emotionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isNearBottomRef = useRef(true);
// Load history + welcome-back. Beide Side-Effects sind store-cached:
// - historyLoaded → kein Re-Fetch + kein Spinner-Blink bei Tab-Wechsel
// - welcomeBackShownThisSession → keine doppelte Lyra-Begrüßung
useEffect(() => {
let cancelled = false;
// Aktuelle Werte aus dem Store lesen (statt Closure-Stale beim ersten Render).
const snap = useCoachStore.getState();
const needsHistory = !snap.historyLoaded;
const needsWelcomeBack = !snap.welcomeBackShownThisSession;
if (!needsHistory && !needsWelcomeBack) {
// Coach war diese Session schon offen → instant rendern, kein Spinner.
requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: false }));
return;
}
async function init() {
// Spinner nur wenn wir wirklich History fetchen müssen (erster Coach-Open).
if (needsHistory) setIsLoading(true);
if (needsHistory) {
try {
await loadHistory();
} catch {
// non-fatal
}
}
if (cancelled) return;
if (needsWelcomeBack) {
try {
const res = await apiFetch<{ message?: string }>('/api/lyra/welcome-back');
if (!cancelled && res?.message) {
pushMessage({ id: 'wb-' + Date.now(), role: 'assistant', content: res.message });
}
} catch {
// no welcome-back — silent
} finally {
if (!cancelled) setWelcomeBackShown(true);
}
}
if (!cancelled) {
if (needsHistory) 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 ttsRes = await fetch(`${apiBase}/api/coach/speak-openai`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
},
body: JSON.stringify({ text: res.message, locale: i18n.language }),
});
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);
recordingTimer.current = setInterval(() => setRecordingDuration((d) => d + 1), 1000);
}
function stopRecordingTimer() {
if (recordingTimer.current) clearInterval(recordingTimer.current);
recordingTimer.current = null;
setRecordingDuration(0);
}
async function onMicDown() {
if (thinking || isTranscribing || isRecording || micHeld.current) return;
if (isSpeaking) stopSpeaking();
const { status } = await Audio.requestPermissionsAsync();
if (status !== 'granted') return;
micHeld.current = true;
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
const rec = new Audio.Recording();
try {
await rec.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
await rec.startAsync();
recordingRef.current = rec;
setIsRecording(true);
startRecordingTimer();
} catch {
micHeld.current = false;
await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
}
}
async function cancelRecording() {
if (!isRecording) return;
micHeld.current = false;
stopRecordingTimer();
setIsRecording(false);
try {
await recordingRef.current?.stopAndUnloadAsync();
} catch { /* already stopped */ }
recordingRef.current = null;
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} 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 }]}
pointerEvents="none"
/>
{/* Floating header — no bar, avatar + 2 icon buttons hover over chat */}
<View style={[styles.topBar, { top: insets.top + 6 }]}>
<Pressable style={styles.backBtn} onPress={() => router.replace('/(app)' as never)} hitSlop={12}>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
<View style={styles.avatarCenter}>
<View pointerEvents="none">
<RiveAvatar emotion={emotion} size="md" />
</View>
<View style={styles.avatarMeta}>
<Text style={styles.avatarName}>{t('coach.lyra')}</Text>
{isSpeaking && (
<View style={styles.speakingRow}>
<VoiceBars count={5} baseColor={colors.brandOrange} />
<Text style={[styles.speakingLabel, { color: colors.brandOrange }]}>{t('coach.speaking')}</Text>
<Pressable style={styles.stopBtn} onPress={stopSpeaking} hitSlop={6}>
<Ionicons name="square" size={10} color={colors.brandOrange} />
</Pressable>
</View>
)}
</View>
</View>
<Pressable style={styles.newChatBtn} onPress={handleNewChat} hitSlop={12}>
<Ionicons name="refresh-outline" size={22} color={colors.textMuted} />
</Pressable>
</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}>
<ThinkingDots />
</View>
</View>
) : null
}
/>
{/* Scroll-to-bottom button */}
{showScrollBtn && (
<Pressable style={styles.scrollDownBtn} onPress={scrollToBottom}>
<Ionicons name="chevron-down" size={18} color="#fff" />
</Pressable>
)}
</View>
)}
{/* Input bar */}
<View style={[styles.inputBar, { paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom) }]}>
{isRecording ? (
<View style={styles.recordingContainer}>
<Pressable style={styles.cancelBtn} onPress={cancelRecording}>
<Ionicons name="trash" size={16} color="#f87171" />
</Pressable>
<View style={styles.pulseDot} />
<Text style={styles.recordingTimer}>{formatDuration(recordingDuration)}</Text>
<View style={{ flex: 1 }}>
<VoiceBars count={18} baseColor="#f87171" />
</View>
</View>
) : isTranscribing ? (
<View style={styles.transcribingRow}>
<Ionicons name="sync" size={16} color={colors.textMuted} />
<Text style={styles.transcribingText}>{t('coach.transcribing')}</Text>
</View>
) : (
<TextInput
style={styles.textInput}
placeholder={t('coach.placeholder')}
placeholderTextColor="#a3a3a3"
value={input}
onChangeText={handleInputChange}
multiline
maxLength={1000}
returnKeyType="send"
onSubmitEditing={handleSend}
/>
)}
{!isTranscribing && (
<Pressable
style={[styles.micBtn, isRecording && styles.micBtnActive, thinking && styles.micBtnDisabled]}
onPressIn={onMicDown}
onPressOut={onMicUp}
disabled={thinking}
>
<Ionicons
name={isRecording ? 'square' : 'mic'}
size={18}
color={isRecording ? '#fff' : colors.textMuted}
/>
</Pressable>
)}
{!isRecording && !isTranscribing && input.trim() !== '' && (
<Pressable
style={[styles.sendBtn, thinking && styles.sendBtnDisabled]}
onPress={handleSend}
disabled={thinking || !input.trim()}
>
<Ionicons name="send" size={16} color="#fff" />
</Pressable>
)}
</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: colors.textMuted,
},
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: 38,
height: 38,
borderRadius: 19,
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,
},
recordingContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'rgba(220,38,38,0.08)',
borderWidth: 1,
borderColor: 'rgba(220,38,38,0.2)',
borderRadius: 22,
paddingHorizontal: 12,
paddingVertical: 8,
},
cancelBtn: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(220,38,38,0.15)',
alignItems: 'center',
justifyContent: 'center',
},
pulseDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#dc2626',
},
recordingTimer: {
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#f87171',
fontVariant: ['tabular-nums'],
},
transcribingRow: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingVertical: 10,
},
transcribingText: {
fontSize: 14,
color: '#737373',
fontFamily: 'Nunito_400Regular',
},
});