chahinebrini 63fae25531 fix(android-protection): explicit specialUse FGS type — Samsung/Android 16 crash loop
RebreakVpnService.onStartCommand crashed with SecurityException because Android 16's validateForegroundServiceType rejects the implicit 2-arg startForeground(). Now passes FOREGROUND_SERVICE_TYPE_SPECIAL_USE explicitly (Google's documented best practice) and guards the call so a failed foreground promotion stops the service cleanly instead of crashing the app. Verified vs reported Galaxy A54 / Android 16 signature (97% of crash events, 1-user crash loop).

Bundles pending working-tree work across native/marketing/locales/mac + graphify-out rebuild. gitignore: google-services.json + /screenshots/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:33:28 +02:00

941 lines
31 KiB
TypeScript
Raw Permalink 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) {
// 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 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);
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 }) => <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',
},
});