- VoiceBars: active=false → kleine Punkte (Stille), active=true → animierte Bars (Sprechen). Übergang fließend via Animated.timing. - Metering via isMeteringEnabled:true + getStatusAsync() alle 200ms. audioLevel (0-1) aus dBFS normalisiert. Threshold >0.1 = Sprechen. - Trash-Button: 400ms roter Flash (backgroundColor + Icon-Farbe) beim Klick bevor Recording verschwindet — wie Instagram. - Timer: Date.now()-basiert statt Increment → kein Android-setInterval-Jitter. - VoiceBars volle Breite via flex:1 + justifyContent:space-evenly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1070 lines
35 KiB
TypeScript
1070 lines
35 KiB
TypeScript
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';
|
||
|
||
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, active }: { count: number; baseColor: string; active: boolean }) {
|
||
const anims = useRef(Array.from({ length: count }, () => new Animated.Value(3))).current;
|
||
const runningRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
if (active && !runningRef.current) {
|
||
runningRef.current = true;
|
||
const animations = anims.map((a, i) =>
|
||
Animated.loop(
|
||
Animated.sequence([
|
||
Animated.timing(a, { toValue: 3 + Math.random() * 14, duration: 400 + (i % 5) * 90, useNativeDriver: false }),
|
||
Animated.timing(a, { toValue: 3, duration: 400 + (i % 5) * 90, useNativeDriver: false }),
|
||
])
|
||
)
|
||
);
|
||
animations.forEach((a) => a.start());
|
||
return () => { animations.forEach((a) => a.stop()); runningRef.current = false; };
|
||
} else if (!active) {
|
||
// Stille: alle Bars auf minimale Höhe zurück
|
||
anims.forEach((a) => Animated.timing(a, { toValue: 3, duration: 150, useNativeDriver: false }).start());
|
||
runningRef.current = false;
|
||
}
|
||
}, [active]);
|
||
|
||
return (
|
||
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-evenly', height: 20 }}>
|
||
{anims.map((a, i) => (
|
||
<Animated.View
|
||
key={i}
|
||
style={{
|
||
width: active ? 2.5 : 2,
|
||
height: active ? a : 2, // Stille: kleine Punkte
|
||
borderRadius: 2,
|
||
backgroundColor: baseColor,
|
||
opacity: active ? 0.75 : 0.4,
|
||
}}
|
||
/>
|
||
))}
|
||
</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 recordingStartTime = useRef<number>(0);
|
||
const [audioLevel, setAudioLevel] = useState(0); // 0–1, 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 + 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 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 0–1.
|
||
const normalized = Math.max(0, Math.min(1, (status.metering + 60) / 60));
|
||
setAudioLevel(normalized);
|
||
}
|
||
} catch { /* kein Metering auf diesem Gerät */ }
|
||
}, 200);
|
||
}
|
||
function stopRecordingTimer() {
|
||
if (recordingTimer.current) clearInterval(recordingTimer.current);
|
||
recordingTimer.current = null;
|
||
setRecordingDuration(0);
|
||
setAudioLevel(0);
|
||
}
|
||
|
||
async function onMicDown() {
|
||
// micHeld guard verhindert Doppel-Starts — aber wenn ein vorheriger Fehler
|
||
// micHeld.current = true hinterlassen hat ohne isRecording zu setzen,
|
||
// wäre der Mic dauerhaft blockiert. Reset wenn State inkonsistent.
|
||
if (micHeld.current && !isRecording) micHeld.current = false;
|
||
if (thinking || isTranscribing || isRecording || micHeld.current) return;
|
||
if (isSpeaking) stopSpeaking();
|
||
|
||
const { status } = await Audio.requestPermissionsAsync();
|
||
if (status !== 'granted') return;
|
||
|
||
micHeld.current = true;
|
||
try {
|
||
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
|
||
const rec = new Audio.Recording();
|
||
await rec.prepareToRecordAsync({
|
||
...Audio.RecordingOptionsPresets.HIGH_QUALITY,
|
||
isMeteringEnabled: true,
|
||
});
|
||
await rec.startAsync();
|
||
recordingRef.current = rec;
|
||
setIsRecording(true);
|
||
startRecordingTimer();
|
||
} catch {
|
||
micHeld.current = false;
|
||
recordingRef.current = null;
|
||
await Audio.setAudioModeAsync({ allowsRecordingIOS: false }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
async function cancelRecording() {
|
||
if (!isRecording) return;
|
||
// Kurze rote Flash-Animation bevor der State verschwindet
|
||
setTrashFlash(true);
|
||
setTimeout(() => setTrashFlash(false), 400);
|
||
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 }]}>
|
||
<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 ? (
|
||
/* ── Instagram-style Recording Bar ─────────────────────── */
|
||
<View style={[styles.recordingBar, { backgroundColor: colors.surfaceElevated, borderColor: colors.border }]}>
|
||
{/* Trash - links, kurz rot bei Klick */}
|
||
<TouchableOpacity
|
||
style={[
|
||
styles.recSideBtn,
|
||
{ backgroundColor: trashFlash ? 'rgba(220,38,38,0.15)' : colors.bg },
|
||
]}
|
||
onPress={cancelRecording}
|
||
activeOpacity={0.7}
|
||
>
|
||
<Ionicons
|
||
name="trash-outline"
|
||
size={17}
|
||
color={trashFlash ? '#ef4444' : colors.textMuted}
|
||
/>
|
||
</TouchableOpacity>
|
||
|
||
{/* Waveform + Timer - mitte */}
|
||
<View style={styles.recCenter}>
|
||
<View style={[styles.recLiveDot, { backgroundColor: colors.brandOrange }]} />
|
||
<VoiceBars count={22} baseColor={colors.text} active={audioLevel > 0.1} />
|
||
<Text style={[styles.recTimer, { color: colors.textMuted }]}>
|
||
{formatDuration(recordingDuration)}
|
||
</Text>
|
||
</View>
|
||
|
||
{/* Send - rechts */}
|
||
<TouchableOpacity
|
||
style={[styles.recSideBtn, { backgroundColor: colors.brandOrange }]}
|
||
onPress={onMicUp}
|
||
activeOpacity={0.8}
|
||
>
|
||
<Ionicons name="arrow-up" size={18} color="#fff" />
|
||
</TouchableOpacity>
|
||
</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}
|
||
/>
|
||
)}
|
||
|
||
{!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: 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,
|
||
},
|
||
recordingBar: {
|
||
flex: 1,
|
||
height: 44,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
borderWidth: StyleSheet.hairlineWidth,
|
||
borderRadius: 22,
|
||
paddingHorizontal: 6,
|
||
},
|
||
recSideBtn: {
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 16,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
recCenter: {
|
||
flex: 1,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
},
|
||
recLiveDot: {
|
||
width: 7,
|
||
height: 7,
|
||
borderRadius: 3.5,
|
||
},
|
||
recTimer: {
|
||
fontSize: 12,
|
||
fontFamily: 'Nunito_600SemiBold',
|
||
fontVariant: ['tabular-nums'],
|
||
minWidth: 32,
|
||
},
|
||
transcribingRow: {
|
||
flex: 1,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 8,
|
||
paddingVertical: 10,
|
||
},
|
||
transcribingText: {
|
||
fontSize: 14,
|
||
color: '#737373',
|
||
fontFamily: 'Nunito_400Regular',
|
||
},
|
||
});
|