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>
978 lines
31 KiB
TypeScript
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',
|
|
},
|
|
});
|