Per strategist-spec: Lyra-Coach-Mode klarer von SOS-Mode trennen. - SOS-Mode (urge): crisis-intervention, focused, kurz - Coach-Mode (lyra): casual, profile-building, philosophy, features Backend (backend/server/api/coach/message.post.ts): - COACH_CASUAL_SYSTEM_PROMPT komplett neu strukturiert (~620 tokens) - Stärkerer Fokus: 3 explicit Aufträge (echtes Gespräch / Profile-Building / Rebreak sprechen) - Profile-building-mandate: "wenn du wenig weißt, sag's ehrlich; frag nach Hobbies/Zielen/Menschen — eingewoben, NICHT als Checkliste" - Cleanere Mission-Section: Bewegung, Anonymität, kein-pathologisieren, community-getrieben, DiGA-Listung-Ziel - Hard-rules klarer: NIE demographics extrahieren (User-Form ist tabu), kein Sucht-Vokabular, kein medical-advice - Existing PLAN_DETAILS-template-var bleibt - Memory-system unverändert (lyra-memories table, extractAndStoreMemories fire-and-forget — kein schema-change nötig) Frontend Mode-Badges: - app/lyra.tsx (Coach-Mode): Header-pill "Coach" in brandOrange-tint neben Lyra-name - app/urge.tsx (SOS-Mode): Header-pill "SOS" in error/red-tint neben Lyra-name (alt: "Lyra · SOS [v2]" inline-text → cleaner badge-style) i18n: - coach.modeBadge.coach + coach.modeBadge.sos in DE + EN Switch-Logic: route-based (lyra.tsx vs urge.tsx → separate persona via backend endpoint). Kein User-Toggle — User soll nicht entscheiden müssen "bin ich grade in Krise?". Implementation Risk: LOW — schema-neutral, prompt-only + 2 small UI badges. Erste Beta-Testing-Phase: ~1-2 Wochen iterieren bei Feedback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1007 lines
32 KiB
TypeScript
1007 lines
32 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 { useColors } from '../lib/theme';
|
|
import { useThemeStore } from '../stores/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() {
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ── 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 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 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, { 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 }]}>
|
|
<Pressable 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}>
|
|
<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}>
|
|
<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} />
|
|
<Text style={[styles.speakingLabel, { color: colors.brandOrange }]}>{t('coach.speaking')}</Text>
|
|
<Pressable style={[styles.stopBtn, { backgroundColor: colors.surfaceElevated }]} onPress={stopSpeaking} hitSlop={6}>
|
|
<Ionicons name="square" size={10} color={colors.brandOrange} />
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
<Pressable style={[styles.newChatBtn, { backgroundColor: colorScheme === 'dark' ? 'rgba(44,44,46,0.92)' : 'rgba(255,255,255,0.92)' }]} 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, { backgroundColor: colors.surfaceElevated }]}>
|
|
<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), backgroundColor: colors.bg, borderTopColor: colors.border }]}>
|
|
{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, { 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}
|
|
/>
|
|
)}
|
|
|
|
{!isTranscribing && (
|
|
<Pressable
|
|
style={[styles.micBtn, { backgroundColor: colors.surfaceElevated }, 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: '#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: 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',
|
|
},
|
|
});
|