Profile (3 Iterationen):
- app/profile/index.tsx + components/profile/* (Header, StatsBar, Approved,
Streak, UrgeStats, Demographics, DigaMissionBanner)
- echte Live-Daten via useMe-Hook (Avatar/Nickname/Plan/Email/Provider-Pill)
- Demographics mit echten Inputs (TextInput + Bottom-Sheet-Selects),
debounced auto-save, Pro-Trial-Reward-Banner, Mikro-Why-Texte
- Approved Domains als plain integer (KEIN Plan-Slot/Cap)
- Friendly Hint-Text statt Progress-Bar (alignSelf:'stretch' Pattern)
- StatsBar zentriert mit 3 prominenten Cards (vertikale Dividers)
- Cooldown-Timeline als Liste mit 1px-Rail
- ApprovedDomainsList: Collapse-Chevron rechts in Title-Row (Pattern-Fix)
- Eigene vs fremde Profile-Ansicht streng getrennt (DSGVO/Anonymität)
Header-Dropdown (kein 3-Punkte-Icon):
- Avatar als Trigger im AppHeader (User-Wunsch)
- Custom-Modal beide Plattformen, Card-Style
- SOS prominent oben (nur Wort 'SOS' rot, Tagline 'wir sind für dich da' klein darunter)
- Profile/Settings/Games/Debug(__DEV__)/Logout
- Logout neutral (nicht rot — Recovery-tonal)
- AppHeader: neue showBack + title Props für Sub-Routes
Routes (Stub bis Phase C):
- app/profile/[userId].tsx — anonym (nur public-Stats)
- app/settings.tsx — Coming-Soon-Skeleton
- app/games.tsx — Standalone Games-Page mit GameCard-Grid
- app/debug.tsx — __DEV__-only
Game-Picker (Migration aus Nuxt):
- components/games/{GameCard, StarRating, GameRatingStars}
- 2x2 Grid, 56pt SVG-Icons (inline aus components/urge/gameSvgs.ts)
- Live-Backend /api/games/ratings (silent-fail)
- Re-use UrgeGames.tsx ohne TTS/Cooldown-Loop
UI-Pattern-Fixes (alle aus screenshot-User-Feedback 2026-05-07):
- Snake-Bug (food-pellet React-18-StrictMode-Reducer-double-call) gefixt
- Snake-Buttons platform-native (iOS-blue / Android-ripple)
- Tetris-Margins (16px paddingHorizontal)
- PostCard-Buttons Apple-44pt-Hit-Area (Image-Select, Image-Remove,
Cancel, Share-Pill — via hitSlop)
- ProfileHeader Demographics-Hint: alignSelf:'stretch' Pattern
- ApprovedDomainsList Collapse: Title flex:1 + Chevron rechts
- ProtectionDetailsSheet FAQ-Items: alignSelf:'stretch' defensive
- AppHeader Back-Button: neue showBack-Prop + chevron-back
Memory + Plan-Docs:
- 17 Memory-Files dokumentieren System-Wissen + Patterns
- ops/{CUTOVER, UI_MIGRATION, PROFILE_PAGE, WEBHOOK, GAMES_1V1,
RELEASE_READINESS, TESTING_STATE, MAESTRO_HOSTING}_*.md
Backend bleibt unverändert (Tier-LLM + Nickname + sort:latency
sind seit gestern deployed).
1334 lines
59 KiB
TypeScript
1334 lines
59 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import {
|
||
View, Text, TextInput, FlatList, Pressable, Platform, Animated,
|
||
Keyboard, KeyboardAvoidingView, StyleSheet, NativeSyntheticEvent,
|
||
NativeScrollEvent, ActivityIndicator, AppState,
|
||
} 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, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av';
|
||
import * as FileSystem from 'expo-file-system';
|
||
import Constants from 'expo-constants';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { RiveAvatar } from '../components/RiveAvatar';
|
||
import { apiFetch } from '../lib/api';
|
||
import { supabase } from '../lib/supabase';
|
||
import { colors } from '../lib/theme';
|
||
import {
|
||
type GameType, GAME_META, MemoryGame, TicTacToeGame, SnakeGame, TetrisGame,
|
||
} from '../components/urge/UrgeGames';
|
||
import { SosFeedbackModal, type SosFeedback } from '../components/urge/SosFeedbackModal';
|
||
import { ShareSuccessDrawer } from '../components/urge/ShareSuccessDrawer';
|
||
import { InlineRatingDrawer } from '../components/urge/InlineRatingDrawer';
|
||
import { BreathingDrawer } from '../components/urge/Breathing';
|
||
import GamePickerDrawer from '../components/urge/GamePickerDrawer';
|
||
import { VoiceBars } from '../components/urge/InlineIndicators';
|
||
import MessageRow, { GameHeader, type SosMsg } from '../components/urge/MessageRow';
|
||
import { SOS_BOOT } from '../lib/sosPrompts';
|
||
import { CHIP_SETS, BREATH_PHASES, type ChipSet } from '../lib/sosConstants';
|
||
import { parseLyraResponse, detectEmotion, type LyraEmotion, type ChipSpec } from '../lib/lyraResponse';
|
||
import { streamSosLyra } from '../lib/sosStream';
|
||
import { SosTtsQueue } from '../lib/sosTtsQueue';
|
||
import { endpointForProvider, useTtsProvider, currentProvider, type TtsProvider } from '../lib/ttsProvider';
|
||
import { TtsProviderToggle } from '../components/urge/TtsProviderToggle';
|
||
import { LlmProviderToggle } from '../components/urge/LlmProviderToggle';
|
||
import { currentLlmProvider } from '../lib/llmProvider';
|
||
import { BenchSession } from '../lib/sosTtsBenchmark';
|
||
|
||
// ── Main Screen ───────────────────────────────────────────────────────────────
|
||
|
||
export default function SOSScreen() {
|
||
const { t, i18n } = useTranslation();
|
||
const router = useRouter();
|
||
const insets = useSafeAreaInsets();
|
||
const flatRef = useRef<FlatList>(null);
|
||
|
||
const [messages, setMessages] = useState<SosMsg[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isBreathing, setIsBreathing] = useState(false);
|
||
const [input, setInput] = useState('');
|
||
const [thinking, setThinking] = useState(false);
|
||
const [emotion, setEmotion] = useState<LyraEmotion>('idle');
|
||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||
const [isTtsLoading, setIsTtsLoading] = useState(false);
|
||
const [soundEnabled, setSoundEnabled] = useState(true);
|
||
const soundEnabledRef = useRef(true);
|
||
const [chipSet, setChipSet] = useState<ChipSet>('start');
|
||
const [dynamicChips, setDynamicChips] = useState<ChipSpec[]>([]);
|
||
const [userTurnCount, setUserTurnCount] = useState(0);
|
||
|
||
// ——— Session-Tracking für DiGA ———
|
||
const sessionStartRef = useRef<Date>(new Date());
|
||
const breathingCountRef = useRef(0);
|
||
const gamesPlayedRef = useRef<Array<{ game: string; score: number; durationSec: number }>>([]);
|
||
const wasOvercomeRef = useRef(false);
|
||
const sessionSavedRef = useRef(false);
|
||
// Hint-Trigger: User klickt "weiter reden" nach Atmen/Spiel
|
||
const requestStatsReminderRef = useRef(false);
|
||
// Hint-Trigger: nach Atmen/Spiel → Lyra soll Check-in machen
|
||
const requestCheckInRef = useRef<'after_breathing' | 'after_game' | null>(null);
|
||
// Hint-Trigger: nach Überwinden → Lyra soll zu Share/Rate motivieren
|
||
const requestOvercomeMotivationRef = useRef(false);
|
||
// Game-Score-Tracking: PB vor dem aktuellen Spielstart (für Vergleich nach Spiel-Ende)
|
||
const pbBeforeGameRef = useRef<number>(0);
|
||
// Hint-Trigger: nach Spiel-Ende mit neuem Personal-Best → Lyra soll feiern
|
||
const requestNewPbCelebrationRef = useRef<{ game: string; oldScore: number; newScore: number } | null>(null);
|
||
|
||
// Exit-Feedback-Modal
|
||
const [feedbackVisible, setFeedbackVisible] = useState(false);
|
||
const exitingRef = useRef(false);
|
||
// Inline-Feedback (Drawer in der Chat-Session) — wenn gegeben, kein Exit-Modal mehr
|
||
const inlineFeedbackRef = useRef<SosFeedback | null>(null);
|
||
const [ratingDrawerVisible, setRatingDrawerVisible] = useState(false);
|
||
// Share-Success Drawer
|
||
const [shareDrawerVisible, setShareDrawerVisible] = useState(false);
|
||
const [shareDraft, setShareDraft] = useState('');
|
||
const [shareGenerating, setShareGenerating] = useState(false);
|
||
const sharePostedRef = useRef(false);
|
||
const [isPickingGame, setIsPickingGame] = useState(false);
|
||
const [breathingDone, setBreathingDone] = useState(false);
|
||
const [playingGame, setPlayingGame] = useState<GameType | null>(null);
|
||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||
|
||
const ttsRef = useRef<Audio.Sound | null>(null);
|
||
const ttsAbortRef = useRef<AbortController | null>(null);
|
||
// Phase B: sentence-level streaming TTS — eine Queue pro sendToLyra-Call.
|
||
const ttsQueueRef = useRef<SosTtsQueue | null>(null);
|
||
const emotionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const isNearBottomRef = useRef(true);
|
||
|
||
useEffect(() => { soundEnabledRef.current = soundEnabled; }, [soundEnabled]);
|
||
|
||
// Aktueller TTS-Provider — currentProvider() liest immer den frischen Wert,
|
||
// ttsProvider state ist nur für UI-Re-Renders + cache-invalidation hier.
|
||
const [ttsProvider] = useTtsProvider();
|
||
|
||
// Pre-cache der Atemübungs-Voice-Cues (Einatmen / Halten / Ausatmen).
|
||
// Bei Phase-Wechsel im BreathingDrawer kommt das Audio sofort statt mit
|
||
// ~600ms TTS-Roundtrip — so bleibt Voice synchron mit der Pulse-Animation.
|
||
// Cache wird invalidiert wenn User den TTS-Provider wechselt.
|
||
const breathAudioCacheRef = useRef<Map<string, string>>(new Map());
|
||
useEffect(() => {
|
||
breathAudioCacheRef.current.clear();
|
||
const phrases = BREATH_PHASES
|
||
.map((p) => p.speakLine)
|
||
.filter((s): s is string => Boolean(s));
|
||
let cancelled = false;
|
||
(async () => {
|
||
for (const text of phrases) {
|
||
if (cancelled) return;
|
||
const audio = await fetchTtsAudio(text).catch(() => null);
|
||
if (cancelled) return;
|
||
if (audio) breathAudioCacheRef.current.set(text, audio.uri);
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [ttsProvider]);
|
||
|
||
// Audio-Mode: bei SOS-Mount Audio-Session konfigurieren.
|
||
// - playsInSilentModeIOS: Lyra spricht auch wenn iPhone auf "stumm"
|
||
// - shouldDuckAndroid: andere Audio-Quellen (Spotify) leiser machen wenn Lyra spricht
|
||
// - staysActiveInBackground: false → OS pausiert TTS automatisch wenn App im Background
|
||
// Plus warm-up: Audio.Sound.createAsync hat auf Android ~500ms Cold-Start.
|
||
// Wir feuern einen no-op-load + unload damit ExoPlayer warm ist bevor Lyra spricht.
|
||
useEffect(() => {
|
||
Audio.setAudioModeAsync({
|
||
allowsRecordingIOS: false,
|
||
playsInSilentModeIOS: true,
|
||
staysActiveInBackground: false,
|
||
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
||
shouldDuckAndroid: true,
|
||
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
|
||
playThroughEarpieceAndroid: false,
|
||
}).catch((err) => {
|
||
console.warn('[sos-audio-mode] failed:', err);
|
||
});
|
||
}, []);
|
||
|
||
// Cleanup-Effect: bei Component-Unmount (router.back, hardware-back, replace,
|
||
// ...) ALLE TTS-Resources freigeben — damit Lyra nicht im Background weiter
|
||
// redet wenn der User die SOS-Page verlässt. User-Bug 2026-05-04 auf A50.
|
||
useEffect(() => {
|
||
return () => {
|
||
ttsAbortRef.current?.abort();
|
||
ttsAbortRef.current = null;
|
||
if (ttsRef.current) {
|
||
const s = ttsRef.current;
|
||
ttsRef.current = null;
|
||
s.stopAsync().catch(() => {});
|
||
s.unloadAsync().catch(() => {});
|
||
}
|
||
ttsQueueRef.current?.abort();
|
||
ttsQueueRef.current = null;
|
||
if (emotionTimer.current) {
|
||
clearTimeout(emotionTimer.current);
|
||
emotionTimer.current = null;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// AppState: wenn App in den Background geht (Home-Button, App-Switcher,
|
||
// Lock-Screen) → TTS stoppen. Sonst spricht Lyra durch's Lock-Screen weiter.
|
||
useEffect(() => {
|
||
const sub = AppState.addEventListener('change', (state) => {
|
||
if (state === 'background' || state === 'inactive') {
|
||
ttsAbortRef.current?.abort();
|
||
ttsAbortRef.current = null;
|
||
if (ttsRef.current) {
|
||
const s = ttsRef.current;
|
||
ttsRef.current = null;
|
||
s.stopAsync().catch(() => {});
|
||
s.unloadAsync().catch(() => {});
|
||
}
|
||
ttsQueueRef.current?.abort();
|
||
ttsQueueRef.current = null;
|
||
setIsSpeaking(false);
|
||
setIsTtsLoading(false);
|
||
}
|
||
});
|
||
return () => sub.remove();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
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(); };
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (messages.length === 0) return;
|
||
if (isNearBottomRef.current) requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true }));
|
||
else setShowScrollBtn(true);
|
||
}, [messages.length, thinking, isBreathing]);
|
||
|
||
function handleScroll(e: NativeSyntheticEvent<NativeScrollEvent>) {
|
||
const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent;
|
||
const dist = contentSize.height - contentOffset.y - layoutMeasurement.height;
|
||
isNearBottomRef.current = dist < 80;
|
||
if (isNearBottomRef.current) setShowScrollBtn(false);
|
||
}
|
||
|
||
function addMessage(msg: SosMsg) { setMessages((prev) => [...prev, msg]); }
|
||
function scheduleEmotionReset(delay = 4000) {
|
||
if (emotionTimer.current) clearTimeout(emotionTimer.current);
|
||
emotionTimer.current = setTimeout(() => setEmotion('idle'), delay);
|
||
}
|
||
|
||
function stopSpeaking() {
|
||
ttsAbortRef.current?.abort();
|
||
ttsAbortRef.current = null;
|
||
ttsRef.current?.stopAsync().catch(() => {});
|
||
ttsRef.current?.unloadAsync().catch(() => {});
|
||
ttsRef.current = null;
|
||
// Phase B: laufende Sentence-Queue auch abbrechen
|
||
ttsQueueRef.current?.abort();
|
||
ttsQueueRef.current = null;
|
||
setIsSpeaking(false); setIsTtsLoading(false); setEmotion('idle');
|
||
}
|
||
|
||
// Holt Audio-MP3 von speak-openai und speichert als temporäre Datei
|
||
async function fetchTtsAudio(rawText: string): Promise<{ uri: string; controller: AbortController } | null> {
|
||
if (!soundEnabledRef.current) return null;
|
||
const text = rawText.replace(/\s+/g, ' ').trim();
|
||
if (!text) return null;
|
||
const controller = new AbortController();
|
||
const session = (await supabase.auth.getSession()).data.session;
|
||
if (controller.signal.aborted) return null;
|
||
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
|
||
// Endpoint folgt User-Provider-Toggle (TtsProviderToggle im SOS-Header).
|
||
const endpoint = endpointForProvider(currentProvider());
|
||
const isGoogleCloud = endpoint.endsWith('/speak-google');
|
||
const ttsRes = await fetch(`${apiBase}${endpoint}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
|
||
},
|
||
body: JSON.stringify({ text, locale: i18n.language, mode: 'sos' }),
|
||
signal: controller.signal,
|
||
});
|
||
if (!ttsRes.ok || controller.signal.aborted) return null;
|
||
|
||
let base64: string;
|
||
let ext: 'mp3' | 'wav';
|
||
if (isGoogleCloud) {
|
||
const json = (await ttsRes.json()) as { audio?: string };
|
||
const dataUri = json.audio ?? '';
|
||
const comma = dataUri.indexOf(',');
|
||
if (comma === -1) return null;
|
||
base64 = dataUri.slice(comma + 1);
|
||
ext = 'mp3';
|
||
} else {
|
||
const buffer = await ttsRes.arrayBuffer();
|
||
if (controller.signal.aborted || buffer.byteLength === 0) return null;
|
||
const bytes = new Uint8Array(buffer);
|
||
const chunks: string[] = [];
|
||
const cs = 0x8000;
|
||
for (let i = 0; i < bytes.length; i += cs)
|
||
chunks.push(String.fromCharCode(...bytes.subarray(i, Math.min(i + cs, bytes.length))));
|
||
base64 = btoa(chunks.join(''));
|
||
ext = endpoint.endsWith('/speak-gemini') ? 'wav' : 'mp3';
|
||
}
|
||
|
||
const tmpPath = `${FileSystem.cacheDirectory}sos-tts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||
await FileSystem.writeAsStringAsync(tmpPath, base64, { encoding: FileSystem.EncodingType.Base64 });
|
||
if (controller.signal.aborted) return null;
|
||
return { uri: tmpPath, controller };
|
||
}
|
||
|
||
// Spielt eine fertige Audio-Datei (oder fetcht sie wenn nicht vorhanden) und wartet auf didJustFinish
|
||
async function playTtsAudio(rawText: string, prefetched: { uri: string; controller: AbortController } | null): Promise<void> {
|
||
if (!soundEnabledRef.current) return;
|
||
// Bestehenden Audio NICHT abbrechen — wir wollen nahtlos anschließen
|
||
setIsTtsLoading(!prefetched);
|
||
let audio = prefetched;
|
||
if (!audio) {
|
||
try { audio = await fetchTtsAudio(rawText); } catch { audio = null; }
|
||
}
|
||
if (!audio) { setIsTtsLoading(false); return; }
|
||
ttsAbortRef.current = audio.controller;
|
||
try {
|
||
const { sound } = await Audio.Sound.createAsync({ uri: audio.uri }, { shouldPlay: true });
|
||
if (audio.controller.signal.aborted) { sound.unloadAsync(); setIsTtsLoading(false); return; }
|
||
setIsTtsLoading(false);
|
||
ttsRef.current = sound;
|
||
setIsSpeaking(true);
|
||
await new Promise<void>((resolve) => {
|
||
const aborted = () => { resolve(); };
|
||
audio!.controller.signal.addEventListener('abort', aborted);
|
||
sound.setOnPlaybackStatusUpdate((status) => {
|
||
if (status.isLoaded && status.didJustFinish) {
|
||
setIsSpeaking(false);
|
||
scheduleEmotionReset(0);
|
||
sound.unloadAsync().catch(() => {});
|
||
audio!.controller.signal.removeEventListener('abort', aborted);
|
||
resolve();
|
||
}
|
||
});
|
||
});
|
||
} catch (err: any) {
|
||
if (err?.name === 'AbortError' || audio.controller.signal.aborted) { setIsTtsLoading(false); return; }
|
||
console.warn('[sos-tts]', err);
|
||
setIsTtsLoading(false);
|
||
setIsSpeaking(false);
|
||
}
|
||
}
|
||
|
||
// Legacy: einmaliger TTS-Call (für nicht-Streaming Pfade z.B. opening greeting)
|
||
async function speakText(rawText: string): Promise<void> {
|
||
if (!soundEnabledRef.current) return;
|
||
ttsAbortRef.current?.abort();
|
||
if (ttsRef.current) {
|
||
ttsRef.current.stopAsync().catch(() => {});
|
||
ttsRef.current.unloadAsync().catch(() => {});
|
||
ttsRef.current = null;
|
||
setIsSpeaking(false);
|
||
}
|
||
// Cache-hit (Atemübung-Phrasen): instant playback, kein API-roundtrip.
|
||
const cleaned = rawText.replace(/\s+/g, ' ').trim();
|
||
const cachedUri = breathAudioCacheRef.current.get(cleaned);
|
||
if (cachedUri) {
|
||
await playTtsAudio(cleaned, { uri: cachedUri, controller: new AbortController() });
|
||
return;
|
||
}
|
||
const audio = await fetchTtsAudio(rawText).catch(() => null);
|
||
if (!audio) return;
|
||
await playTtsAudio(rawText, audio);
|
||
}
|
||
|
||
async function sendToLyra(userText: string) {
|
||
if (thinking) return;
|
||
if (isSpeaking) stopSpeaking();
|
||
addMessage({ id: Date.now().toString(), role: 'user', content: userText, timestamp: new Date() });
|
||
setUserTurnCount((n) => n + 1);
|
||
setThinking(true); setEmotion('thinking');
|
||
// Latenz-Benchmark — eine Session pro sendToLyra-Call. Marker werden in
|
||
// stream/queue über onMetric gesammelt, gedruckt im onIdle (oder als
|
||
// Fallback im finally bei Errors / sound-off).
|
||
const bench = new BenchSession({ provider: currentProvider(), llm: currentLlmProvider(), label: 'send' });
|
||
try {
|
||
const visibleHistory = messages.filter((m) => !m.cardType).map((m) => ({ role: m.role, content: m.content }));
|
||
|
||
// ----- Hint-Builder -----
|
||
const hints: string[] = [];
|
||
|
||
// (1) Check-in nach Atmen/Spiel
|
||
const checkIn = requestCheckInRef.current;
|
||
requestCheckInRef.current = null;
|
||
if (checkIn === 'after_breathing') {
|
||
hints.push('[SYSTEM-HINT] Der User hat gerade die Atemübung beendet. Frage warm nach, wie er sich JETZT fühlt. Biete 3-4 Chips: "🫁 Nochmal atmen", "🎮 Spiel", "💭 An was anderes denken", "😄 Erzähl mir einen Witz".');
|
||
} else if (checkIn === 'after_game') {
|
||
hints.push('[SYSTEM-HINT] Der User hat gerade das Spiel beendet. Frage warm, wie er sich JETZT fühlt. Biete 3-4 Chips: "🎮 Weiter spielen", "🫁 Atemübung", "💭 An was anderes denken", "😄 Erzähl mir einen Witz".');
|
||
}
|
||
|
||
// (1b) Personal-Best-Celebration — wenn User gerade neuen PB gemacht hat
|
||
if (requestNewPbCelebrationRef.current) {
|
||
const { game, oldScore, newScore } = requestNewPbCelebrationRef.current;
|
||
requestNewPbCelebrationRef.current = null;
|
||
const pbHint = oldScore > 0
|
||
? `[SYSTEM-HINT] NEUER REKORD! Der User hat sein bisheriges Personal-Best in ${game} (${oldScore}) auf ${newScore} verbessert. Feiere das warm in 1-2 Sätzen — nenne KONKRET die Zahlen ${oldScore} → ${newScore}. Mach klar: das ist sein eigener Sieg, ein echter Schritt. Dann der normale after_game-Check-in (siehe oben).`
|
||
: `[SYSTEM-HINT] Erster Score! Der User hat seinen ALLERSTEN Score in ${game}: ${newScore}. Feiere den Erstversuch warm — der erste Score ist immer was Besonderes. Dann der normale after_game-Check-in.`;
|
||
hints.push(pbHint);
|
||
}
|
||
|
||
// (2) Stats-Reminder bei "weiter reden" nach Aktion
|
||
if (requestStatsReminderRef.current) {
|
||
requestStatsReminderRef.current = false;
|
||
const b = breathingCountRef.current;
|
||
const g = gamesPlayedRef.current.length;
|
||
const parts: string[] = [];
|
||
if (b > 0) parts.push(`${b}x Atemübung`);
|
||
if (g > 0) parts.push(`${g}x Spiel`);
|
||
const stats = parts.join(' + ') || 'mehrere Bewältigungs-Tools';
|
||
hints.push(`[SYSTEM-HINT] Erinnere den User WARM und KONKRET: Wir haben in dieser Session schon ${stats} gemacht. Das senkt wissenschaftlich nachweislich den Spielimpuls (z.B. Atemübungen aktivieren den Parasympathikus). Lobe ihn: er hat die Gambling-Industrie heute schon geschlagen. Dann frage, was er jetzt braucht. Chips: "❤️ Überwunden", "🫁 Nochmal atmen", "🎮 Spiel".`);
|
||
}
|
||
|
||
// (3) Nach 3+ Turns: Service-Angebot wenn Lyra noch nicht angeboten hat
|
||
if ((userTurnCount + 1) >= 3 && breathingCountRef.current === 0 && gamesPlayedRef.current.length === 0) {
|
||
hints.push('[SYSTEM-HINT] Ich habe jetzt schon mehrere Male geantwortet. Biete mir JETZT konkret eine Atemübung ODER ein Spiel an. Chips müssen "breathing" + "game_picker" enthalten, optional "send_text:Lass uns weiter reden".');
|
||
}
|
||
|
||
// (4) Nach Überwinden → Lyra motiviert zu Sharing + Bewertung
|
||
if (requestOvercomeMotivationRef.current) {
|
||
requestOvercomeMotivationRef.current = false;
|
||
hints.push(
|
||
'[SYSTEM-HINT] Der User hat den Impuls ÜBERWUNDEN. Feiere ihn warm und KURZ (2-3 Sätze max). Dann motiviere ihn zu ZWEI konkreten Aktionen: ' +
|
||
'(a) Erfolg mit der Community teilen — das stärkt andere Betroffene und macht ihn stolz. ' +
|
||
'(b) Diese Session bewerten + kurze Bemerkung geben — das hilft uns Lyra zu verbessern und die App für DiGA-Zertifizierung qualitativ besser zu machen. ' +
|
||
'WICHTIG: Liefere GENAU diese Chips: ' +
|
||
'[{"label":"✨ Erfolg teilen","action":"share_success"}, {"label":"⭐ Session bewerten","action":"rate_session"}, {"label":"✅ Fertig","action":"close"}]'
|
||
);
|
||
}
|
||
|
||
const hintMsgs = hints.map((h) => ({ role: 'user' as const, content: h }));
|
||
const apiMessages = [...SOS_BOOT, ...visibleHistory, { role: 'user' as const, content: userText }, ...hintMsgs];
|
||
|
||
// ── SSE-Streaming via react-native-sse ──
|
||
const session = (await supabase.auth.getSession()).data.session;
|
||
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
|
||
|
||
if (!session?.access_token) throw new Error('no token');
|
||
|
||
const assistantId = (Date.now() + 1).toString();
|
||
let assistantAdded = false;
|
||
const ensureBubble = (text: string) => {
|
||
if (!assistantAdded) {
|
||
assistantAdded = true;
|
||
addMessage({ id: assistantId, role: 'assistant', content: text, timestamp: new Date() });
|
||
} else {
|
||
setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: text } : m)));
|
||
}
|
||
};
|
||
|
||
let visible = '';
|
||
let parsedChips: Array<{ label: string; action: string }> = [];
|
||
let streamError: any = null;
|
||
// Hybrid-TTS v3 (Threshold 3): warte auf 3 Sätze bevor first-chunk
|
||
// ans TTS geht. Mit prompt "max 3 Sätze" sind 99% aller Antworten
|
||
// single-shot → NULL Voice-Boundary, 100% konsistente Stimme.
|
||
// Trade-off: ~1s mehr First-Audio-Latenz im 3-Satz-Fall vs. v2.
|
||
const firstChunkSentences: string[] = [];
|
||
let firstChunkConsumed = false;
|
||
let firstChunkText = '';
|
||
|
||
// Hybrid-TTS-Queue: erster Satz live + Rest als ein Block. Pre-fetch in
|
||
// der Queue startet sofort beim enqueue → läuft parallel zum Playback
|
||
// des ersten Satzes → null Gap zwischen Satz-1 und Rest.
|
||
ttsQueueRef.current?.abort();
|
||
const ttsQueue = soundEnabledRef.current
|
||
? new SosTtsQueue({
|
||
apiBase,
|
||
accessToken: session.access_token,
|
||
locale: i18n.language,
|
||
endpoint: endpointForProvider(currentProvider()),
|
||
onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); },
|
||
onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); bench.print(); },
|
||
onError: (err, sentence) => {
|
||
console.warn('[sos-tts-queue] segment failed:', sentence.slice(0, 50), err);
|
||
},
|
||
onMetric: bench.mark,
|
||
})
|
||
: null;
|
||
ttsQueueRef.current = ttsQueue;
|
||
if (ttsQueue) setIsTtsLoading(true);
|
||
|
||
await new Promise<void>((resolve) => {
|
||
let cancel: (() => void) | undefined;
|
||
streamSosLyra({
|
||
apiBase,
|
||
token: session.access_token,
|
||
messages: apiMessages,
|
||
locale: i18n.language,
|
||
llmProvider: currentLlmProvider(),
|
||
onMetric: bench.mark,
|
||
onTextUpdate: (full) => {
|
||
visible = full;
|
||
ensureBubble(full);
|
||
},
|
||
onChips: (chips) => {
|
||
parsedChips = chips;
|
||
},
|
||
onSentence: (sentence) => {
|
||
if (firstChunkConsumed) return;
|
||
firstChunkSentences.push(sentence);
|
||
// Trigger erst bei 3 vollständigen Sätzen (war v2: 2). Mit Server-
|
||
// Prompt "max 3 Sätze" landen 99% aller Antworten als single-shot
|
||
// im onDone (count<3) → NULL Boundary, 100% konsistente Stimme.
|
||
// Boundary nur bei seltenen 4+-Satz-Antworten.
|
||
if (firstChunkSentences.length >= 2) {
|
||
firstChunkConsumed = true;
|
||
firstChunkText = firstChunkSentences.join(' ');
|
||
ttsQueue?.enqueue(firstChunkText);
|
||
}
|
||
},
|
||
onDone: (full) => {
|
||
visible = full || visible;
|
||
ensureBubble(visible);
|
||
if (ttsQueue) {
|
||
if (firstChunkConsumed) {
|
||
// Rest = full minus first-chunk. Fester Match via indexOf.
|
||
const idx = full.indexOf(firstChunkText);
|
||
const rest =
|
||
idx >= 0
|
||
? full.slice(idx + firstChunkText.length).trim()
|
||
: full.replace(firstChunkText, '').trim();
|
||
if (rest.length > 0) {
|
||
// Mode 'sos-continuation' → server gibt OpenAI explizite
|
||
// "no fresh-start"-Instructions → Boundary weicher.
|
||
ttsQueue.enqueue(rest, 'sos-continuation');
|
||
}
|
||
} else {
|
||
// <2 Sätze detektiert (kurze Antwort) — kompletten Text als
|
||
// single-shot. Voice 100% konsistent, kein Boundary überhaupt.
|
||
const cleaned = (full || visible).trim();
|
||
if (cleaned) ttsQueue.enqueue(cleaned);
|
||
}
|
||
}
|
||
resolve();
|
||
},
|
||
onError: (err) => {
|
||
streamError = err;
|
||
resolve();
|
||
},
|
||
})
|
||
.then((c) => {
|
||
cancel = c;
|
||
})
|
||
.catch((err) => {
|
||
streamError = err;
|
||
resolve();
|
||
});
|
||
});
|
||
|
||
// Fallback bei Stream-Fehler: alter non-streaming Endpoint
|
||
if (streamError) {
|
||
console.warn('[sos-stream] failed, fallback', streamError);
|
||
// Queue abbrechen damit nicht halb-gefüllter Stream noch Audio spielt
|
||
ttsQueueRef.current?.abort();
|
||
ttsQueueRef.current = null;
|
||
const res = await apiFetch<{ message: string }>('/api/coach/message', {
|
||
method: 'POST',
|
||
body: { messages: apiMessages, locale: i18n.language, sosMode: true },
|
||
});
|
||
const parsed = parseLyraResponse(res?.message ?? '');
|
||
visible = parsed.message;
|
||
parsedChips = parsed.chips;
|
||
ensureBubble(visible);
|
||
if (visible) speakText(visible);
|
||
}
|
||
// Wenn Stream erfolgreich war, Queue aber leer geblieben (z.B. Stream hat
|
||
// 0 Sätze geliefert, Edge-Case bei sehr kurzer LLM-Antwort) → Loading-Flag
|
||
// selbst zurücksetzen, sonst hängt der Spinner.
|
||
if (!streamError && ttsQueue && !ttsQueue.isActive()) {
|
||
setIsTtsLoading(false);
|
||
}
|
||
|
||
const e = detectEmotion(visible);
|
||
|
||
// Fallback-Chips wenn Lyra keine liefert
|
||
let finalChips = parsedChips;
|
||
if (finalChips.length === 0) {
|
||
if (checkIn === 'after_breathing') {
|
||
finalChips = [
|
||
{ label: '🫁 Nochmal atmen', action: 'breathing' },
|
||
{ label: '🎮 Spiel', action: 'game_picker' },
|
||
{ label: '💭 An was anderes denken', action: 'send_text:Lenk mich bitte ab — erzähl mir was Schönes.' },
|
||
{ label: '❤️ Überwunden', action: 'overcome' },
|
||
];
|
||
} else if (checkIn === 'after_game') {
|
||
finalChips = [
|
||
{ label: '🎮 Weiter spielen', action: 'game_picker' },
|
||
{ label: '🫁 Atemübung', action: 'breathing' },
|
||
{ label: '😄 Erzähl mir einen Witz', action: 'send_text:Erzähl mir bitte einen kurzen Witz.' },
|
||
{ label: '❤️ Überwunden', action: 'overcome' },
|
||
];
|
||
} else if ((userTurnCount + 1) >= 3) {
|
||
finalChips = [
|
||
{ label: '🫁 Atemübung', action: 'breathing' },
|
||
{ label: '🎮 Spiel', action: 'game_picker' },
|
||
{ label: '💬 Weiter reden', action: 'send_text:Lass uns weiter reden.' },
|
||
];
|
||
} else {
|
||
finalChips = [
|
||
{ label: '💬 Mehr erzählen', action: 'send_text:Ich möchte mehr erzählen.' },
|
||
{ label: '🫁 Atemübung', action: 'breathing' },
|
||
{ label: '🎮 Ablenken', action: 'game_picker' },
|
||
];
|
||
}
|
||
}
|
||
setDynamicChips(finalChips);
|
||
setChipSet('none');
|
||
setEmotion(e); scheduleEmotionReset(e === 'empathy' ? 6000 : 4000);
|
||
} catch {
|
||
addMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: t('coach.error'), timestamp: new Date() });
|
||
setEmotion('idle');
|
||
} finally {
|
||
setThinking(false);
|
||
// Fallback-Print NUR wenn keine TTS-Queue (mehr) aktiv ist. Sonst feuert
|
||
// das finally bei kurzen Antworten zu früh — der TTS-Fetch läuft dann
|
||
// gerade erst, headers kommen erst Sekunden später, und ein print()
|
||
// hier würde alle TTS-Marker verwerfen. Im aktiven Fall übernimmt
|
||
// ttsQueue.onIdle den Print.
|
||
if (!ttsQueueRef.current?.isActive()) {
|
||
bench.print('finally');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Opening greeting on mount — nutzt gleichen Streaming-Pfad wie sendToLyra,
|
||
// damit Hybrid-TTS auch beim Start greift (sonst dauerte es 4-5s bis Lyra
|
||
// sprach, weil non-streaming /api/coach/message + dann separater TTS-Call).
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
async function openGreeting() {
|
||
setIsLoading(true); setEmotion('thinking'); setThinking(true);
|
||
const greetingId = 'greeting-' + Date.now();
|
||
let assistantAdded = false;
|
||
const ensureBubble = (text: string) => {
|
||
if (!assistantAdded) {
|
||
assistantAdded = true;
|
||
addMessage({ id: greetingId, role: 'assistant', content: text, timestamp: new Date() });
|
||
} else {
|
||
setMessages((prev) => prev.map((m) => (m.id === greetingId ? { ...m, content: text } : m)));
|
||
}
|
||
};
|
||
try {
|
||
const session = (await supabase.auth.getSession()).data.session;
|
||
if (cancelled) return;
|
||
if (!session?.access_token) throw new Error('no token');
|
||
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
|
||
|
||
// Latenz-Benchmark fürs Greeting — gleiches Pattern wie sendToLyra.
|
||
const greetingBench = new BenchSession({ provider: currentProvider(), llm: currentLlmProvider(), label: 'greeting' });
|
||
|
||
// Hybrid-TTS-Queue, gleiches Pattern wie sendToLyra
|
||
const ttsQueue = soundEnabledRef.current
|
||
? new SosTtsQueue({
|
||
apiBase,
|
||
accessToken: session.access_token,
|
||
locale: i18n.language,
|
||
endpoint: endpointForProvider(currentProvider()),
|
||
onStart: () => { setIsSpeaking(true); setIsTtsLoading(false); },
|
||
onIdle: () => { setIsSpeaking(false); setIsTtsLoading(false); scheduleEmotionReset(0); greetingBench.print(); },
|
||
onError: (err, sentence) => {
|
||
console.warn('[sos-tts-greeting] segment failed:', sentence.slice(0, 50), err);
|
||
},
|
||
onMetric: greetingBench.mark,
|
||
})
|
||
: null;
|
||
ttsQueueRef.current?.abort();
|
||
ttsQueueRef.current = ttsQueue;
|
||
if (ttsQueue) setIsTtsLoading(true);
|
||
|
||
const greetingChunkSentences: string[] = [];
|
||
let greetingChunkConsumed = false;
|
||
let greetingChunkText = '';
|
||
let visible = '';
|
||
let parsedChips: Array<{ label: string; action: string }> = [];
|
||
|
||
await new Promise<void>((resolve) => {
|
||
streamSosLyra({
|
||
apiBase,
|
||
token: session.access_token,
|
||
messages: SOS_BOOT,
|
||
locale: i18n.language,
|
||
llmProvider: currentLlmProvider(),
|
||
onMetric: greetingBench.mark,
|
||
onTextUpdate: (full) => {
|
||
if (cancelled) return;
|
||
visible = full;
|
||
ensureBubble(full);
|
||
},
|
||
onChips: (chips) => { parsedChips = chips; },
|
||
onSentence: (s) => {
|
||
if (greetingChunkConsumed) return;
|
||
greetingChunkSentences.push(s);
|
||
if (greetingChunkSentences.length >= 2) {
|
||
greetingChunkConsumed = true;
|
||
greetingChunkText = greetingChunkSentences.join(' ');
|
||
ttsQueue?.enqueue(greetingChunkText);
|
||
}
|
||
},
|
||
onDone: (full) => {
|
||
if (cancelled) { resolve(); return; }
|
||
visible = full || visible;
|
||
ensureBubble(visible);
|
||
if (ttsQueue) {
|
||
if (greetingChunkConsumed) {
|
||
const idx = full.indexOf(greetingChunkText);
|
||
const rest = idx >= 0
|
||
? full.slice(idx + greetingChunkText.length).trim()
|
||
: full.replace(greetingChunkText, '').trim();
|
||
if (rest.length > 0) ttsQueue.enqueue(rest, 'sos-continuation');
|
||
} else {
|
||
const cleaned = (full || visible).trim();
|
||
if (cleaned) ttsQueue.enqueue(cleaned);
|
||
}
|
||
}
|
||
resolve();
|
||
},
|
||
onError: (err) => {
|
||
console.warn('[sos-greeting] stream failed:', err);
|
||
resolve();
|
||
},
|
||
})
|
||
.then(() => {})
|
||
.catch(() => resolve());
|
||
});
|
||
|
||
if (cancelled) return;
|
||
const e = detectEmotion(visible);
|
||
// Fallback-Chips falls Lyra im Greeting keine liefert
|
||
const greetingChips = parsedChips.length > 0 ? parsedChips : [
|
||
{ label: '😤 Wütend', action: 'feel:Ich bin gerade sehr wütend.' },
|
||
{ label: '😰 Ängstlich', action: 'feel:Ich bin ängstlich und nervos.' },
|
||
{ label: '😔 Traurig', action: 'feel:Ich bin traurig.' },
|
||
{ label: '🤔 Etwas anderes', action: 'need_help' },
|
||
];
|
||
setDynamicChips(greetingChips);
|
||
setChipSet('none');
|
||
setEmotion(e); scheduleEmotionReset(e === 'empathy' ? 6000 : 4000);
|
||
} catch (err) {
|
||
if (cancelled) return;
|
||
console.warn('[sos-greeting] failed, fallback message', err);
|
||
addMessage({ id: 'greeting-err', role: 'assistant', content: 'Ich bin für dich da. Was ist gerade los?', timestamp: new Date() });
|
||
setEmotion('empathy'); scheduleEmotionReset(5000);
|
||
} finally { if (!cancelled) { setIsLoading(false); setThinking(false); } }
|
||
}
|
||
openGreeting();
|
||
return () => { cancelled = true; };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
function startBreathing() {
|
||
// Laufendes Lyra-TTS stoppen — sonst spricht sie in die Atemübung rein
|
||
stopSpeaking();
|
||
setIsBreathing(true);
|
||
setChipSet('none');
|
||
}
|
||
|
||
async function handleBreathingDone() {
|
||
setIsBreathing(false);
|
||
setBreathingDone(true);
|
||
breathingCountRef.current += 1;
|
||
setChipSet('after_breathing');
|
||
requestCheckInRef.current = 'after_breathing';
|
||
await sendToLyra('Ich habe gerade die 4-7-8 Atemübung abgeschlossen.');
|
||
}
|
||
|
||
async function handleChip(action: string) {
|
||
if (thinking) return;
|
||
Keyboard.dismiss();
|
||
// Clear dynamic chips on any chip action — Lyra will provide new ones in her reply
|
||
setDynamicChips([]);
|
||
if (action.startsWith('feel:')) {
|
||
const feelingText = action.replace('feel:', '');
|
||
setChipSet('none');
|
||
await sendToLyra(feelingText);
|
||
} else if (action === 'need_help') {
|
||
setChipSet('none');
|
||
await sendToLyra('Ich möchte darüber reden, was gerade los ist.');
|
||
} else if (action === 'just_play') {
|
||
setChipSet('none');
|
||
setIsPickingGame(true);
|
||
} else if (action === 'breathing') {
|
||
startBreathing();
|
||
} else if (action === 'game_picker') {
|
||
setChipSet('none');
|
||
setIsPickingGame(true);
|
||
} else if (action === 'overcome') {
|
||
await finalizeOvercome();
|
||
} else if (action === 'show_stats') {
|
||
setChipSet('none');
|
||
try {
|
||
const res = await apiFetch<{ urges?: number; overcomeCnt?: number; streakDays?: number }>('/api/urge/stats');
|
||
const msg = res
|
||
? `Du hast heute ${res.overcomeCnt ?? 1} von ${res.urges ?? 1} Impulsen widerstanden. Dein Streak: ${res.streakDays ?? 1} Tag(e). Ich bin stolz auf dich! 💪`
|
||
: 'Jeder überwundene Impuls macht dich stärker. Ich bin stolz auf dich! 💪';
|
||
addMessage({ id: 'stats-' + Date.now(), role: 'assistant', content: msg, timestamp: new Date() });
|
||
speakText(msg);
|
||
} catch {
|
||
addMessage({ id: 'stats-err', role: 'assistant', content: 'Jeder überwundene Impuls zählt. Du machst das großartig! 💪', timestamp: new Date() });
|
||
}
|
||
} else if (action === 'close') {
|
||
attemptExit();
|
||
} else if (action === 'share_success') {
|
||
setChipSet('none');
|
||
openShareDrawer();
|
||
} else if (action === 'rate_session') {
|
||
setChipSet('none');
|
||
setRatingDrawerVisible(true);
|
||
} else if (action.startsWith('send_text:')) {
|
||
setChipSet('none');
|
||
const txt = action.replace('send_text:', '');
|
||
// Wenn der User nach Atem/Spiel "weiter reden" klickt → Stats-Reminder triggern
|
||
if ((breathingCountRef.current > 0 || gamesPlayedRef.current.length > 0) &&
|
||
/weiter reden|weiterreden|reden|sprechen/i.test(txt)) {
|
||
requestStatsReminderRef.current = true;
|
||
}
|
||
await sendToLyra(txt);
|
||
}
|
||
}
|
||
|
||
async function handleGameSelect(game: GameType) {
|
||
setIsPickingGame(false);
|
||
setPlayingGame(game);
|
||
const titles: Record<GameType, string> = { snake: 'Snake', tetris: 'Tetris', memory: 'Memory', tictactoe: 'Tic-Tac-Toe' };
|
||
const title = titles[game];
|
||
|
||
// Personal-Best fetchen (parallel zum Game-Start) — Lyra mentions PB im Pep-Talk.
|
||
// Cache pbBeforeGame für späteren Vergleich nach Spiel-Ende (Rekord-Detection).
|
||
let pbContext = '';
|
||
pbBeforeGameRef.current = 0;
|
||
try {
|
||
const pbRes = await apiFetch<{ score: number; hasRecord: boolean }>(`/api/games/highscore?gameName=${encodeURIComponent(game)}`);
|
||
const pb = pbRes?.score ?? 0;
|
||
pbBeforeGameRef.current = pb;
|
||
if (pbRes?.hasRecord && pb > 0) {
|
||
pbContext = ` Sein bisheriger Personal-Best in ${title} ist ${pb}. Erwähne diesen PB konkret im Kommentar UND glaube an ihn dass er es heute schaffen kann.`;
|
||
} else {
|
||
pbContext = ` Es ist sein ALLERSTER ${title}-Versuch. Ermutige zum Erstversuch.`;
|
||
}
|
||
} catch {
|
||
// Silent fallback — Lyra bekommt einfach keinen PB-Hint
|
||
}
|
||
|
||
// Fire-and-forget Lyra-Kommentar (UI launches game immediately)
|
||
(async () => {
|
||
try {
|
||
const promptMsg = `[INTERN: Der User hat das Spiel "${title}" gewählt.${pbContext} Gib EINEN warmen Kommentar (1-2 Sätze) — bei Tetris nostalgisch, Snake spielerisch, Memory ermutigend, Tic-Tac-Toe entspannt. Antworte als reines JSON: {"message":"...","chips":[]}. Kein Markdown.]`;
|
||
const visibleHistory = messages.filter((m) => !m.cardType).map((m) => ({ role: m.role, content: m.content }));
|
||
const apiMessages = [...SOS_BOOT, ...visibleHistory, { role: 'user' as const, content: promptMsg }];
|
||
const res = await apiFetch<{ message: string }>('/api/coach/message', {
|
||
method: 'POST',
|
||
body: { messages: apiMessages, locale: i18n.language, sosMode: true },
|
||
});
|
||
const parsed = parseLyraResponse(res?.message ?? '');
|
||
const visible = parsed.message || `Viel Spaß mit ${title}!`;
|
||
addMessage({ id: 'gamecomment-' + Date.now(), role: 'assistant', content: visible, timestamp: new Date() });
|
||
setEmotion('happy'); scheduleEmotionReset(4000); speakText(visible);
|
||
} catch {
|
||
const fallback = pbBeforeGameRef.current > 0
|
||
? `Viel Spaß mit ${title}! Dein PB ist ${pbBeforeGameRef.current} — ich glaube du schaffst heute mehr.`
|
||
: `Viel Spaß mit ${title}! Ich bin hier, wenn du fertig bist.`;
|
||
addMessage({ id: 'gamecomment-err-' + Date.now(), role: 'assistant', content: fallback, timestamp: new Date() });
|
||
speakText(fallback);
|
||
}
|
||
})();
|
||
}
|
||
|
||
async function handleGameComplete(score: number) {
|
||
const gameName = GAME_META.find((g) => g.id === playingGame)?.id ?? 'das Spiel';
|
||
const game = playingGame ?? 'unknown';
|
||
gamesPlayedRef.current.push({ game, score, durationSec: 0 });
|
||
|
||
// Score persistieren — server entscheidet ob's ein neuer PB ist (idempotent)
|
||
let isNewBest = false;
|
||
try {
|
||
const res = await apiFetch<{ ok: boolean; isNewBest: boolean }>('/api/games/score', {
|
||
method: 'POST',
|
||
body: { gameName: game, score },
|
||
});
|
||
isNewBest = !!res?.isNewBest;
|
||
} catch (err) {
|
||
console.warn('[urge] score submit failed', err);
|
||
}
|
||
|
||
// Wenn Rekord: Lyra im nächsten Reply feiern lassen via Hint-Trigger
|
||
const oldPb = pbBeforeGameRef.current;
|
||
if (isNewBest && score > oldPb) {
|
||
requestNewPbCelebrationRef.current = { game: gameName, oldScore: oldPb, newScore: score };
|
||
}
|
||
|
||
setPlayingGame(null); setChipSet('after_game');
|
||
requestCheckInRef.current = 'after_game';
|
||
await sendToLyra(`Ich habe ${gameName} gespielt und ${score} Punkte erreicht.`);
|
||
}
|
||
|
||
async function handleGameAbandon() {
|
||
const game = playingGame;
|
||
if (game) gamesPlayedRef.current.push({ game, score: 0, durationSec: 0 });
|
||
setPlayingGame(null); setChipSet('after_game');
|
||
requestCheckInRef.current = 'after_game';
|
||
await sendToLyra('Ich habe das Spiel abgebrochen.');
|
||
}
|
||
|
||
async function finalizeOvercome() {
|
||
if (thinking) return;
|
||
setChipSet('none');
|
||
wasOvercomeRef.current = true;
|
||
try { await apiFetch('/api/urge', { method: 'POST', body: { emotion: 'other', wasOvercome: true, breathingDone } }); } catch {}
|
||
// Lyra antwortet warm + motiviert zu Share/Rate
|
||
requestOvercomeMotivationRef.current = true;
|
||
await sendToLyra('Ich habe den Spielimpuls gerade überwunden – ich bin so erleichtert und stolz auf mich!');
|
||
addMessage({ id: 'overcome-' + Date.now(), role: 'assistant', content: '', cardType: 'overcome', timestamp: new Date() });
|
||
// Chips garantiert anzeigen — egal was Lyra zurückgibt (Hint kann verfehlt werden)
|
||
setDynamicChips([
|
||
{ label: '✨ Erfolg teilen', action: 'share_success' },
|
||
{ label: '⭐ Session bewerten', action: 'rate_session' },
|
||
{ label: '✅ Fertig', action: 'close' },
|
||
]);
|
||
setChipSet('none');
|
||
}
|
||
|
||
// ───── Share-Success Drawer ─────
|
||
async function generateShareDraft() {
|
||
setShareGenerating(true);
|
||
try {
|
||
const b = breathingCountRef.current;
|
||
const g = gamesPlayedRef.current.length;
|
||
const stats: string[] = [];
|
||
if (b > 0) stats.push(`${b}x Atemübung`);
|
||
if (g > 0) stats.push(`${g}x Mini-Spiel`);
|
||
const statsLine = stats.length > 0 ? stats.join(' + ') : 'Lyras Begleitung';
|
||
|
||
const promptMsg =
|
||
`[INTERN: Schreibe einen kurzen, ehrlichen, warmen anonymen Community-Post (max 4 Sätze, ich-Form, Deutsch) über meinen heutigen Erfolg. ` +
|
||
`Ich habe einen akuten Spielimpuls überwunden mit ${statsLine}. ` +
|
||
`Inspiriere andere, ohne zu predigen. Kein Hashtag, keine Emojis-Spam (max 1 Emoji am Ende). ` +
|
||
`Antworte als reines JSON: {"message":"<post-text>","chips":[]}. Kein Markdown.]`;
|
||
const visibleHistory = messages
|
||
.filter((m) => !m.cardType && m.content)
|
||
.slice(-10)
|
||
.map((m) => ({ role: m.role, content: m.content }));
|
||
const apiMessages = [...SOS_BOOT, ...visibleHistory, { role: 'user' as const, content: promptMsg }];
|
||
const res = await apiFetch<{ message: string }>('/api/coach/message', {
|
||
method: 'POST',
|
||
body: { messages: apiMessages, locale: i18n.language, sosMode: true },
|
||
});
|
||
const parsed = parseLyraResponse(res?.message ?? '');
|
||
const draft =
|
||
parsed.message?.trim() ||
|
||
'Heute hatte ich einen heftigen Spielimpuls — und ich habe ihn überwunden. Es war hart, aber ich bin geblieben. 💪';
|
||
setShareDraft(draft);
|
||
} catch {
|
||
setShareDraft(
|
||
'Heute hatte ich einen Spielimpuls — und ich habe ihn überwunden. Schritt für Schritt. 💪',
|
||
);
|
||
} finally {
|
||
setShareGenerating(false);
|
||
}
|
||
}
|
||
|
||
async function openShareDrawer() {
|
||
setShareDrawerVisible(true);
|
||
setShareDraft('');
|
||
await generateShareDraft();
|
||
}
|
||
|
||
async function submitSharePost(text: string) {
|
||
if (sharePostedRef.current) return;
|
||
sharePostedRef.current = true;
|
||
try {
|
||
await apiFetch('/api/community/post', {
|
||
method: 'POST',
|
||
body: { category: 'story', content: text },
|
||
});
|
||
addMessage({
|
||
id: 'share-ack-' + Date.now(),
|
||
role: 'assistant',
|
||
content: 'Dein Erfolg ist geteilt — danke, dass du andere stärkst. 💛',
|
||
timestamp: new Date(),
|
||
});
|
||
} catch {
|
||
sharePostedRef.current = false;
|
||
addMessage({
|
||
id: 'share-err-' + Date.now(),
|
||
role: 'assistant',
|
||
content: 'Das Teilen hat gerade nicht geklappt. Versuch es später nochmal.',
|
||
timestamp: new Date(),
|
||
});
|
||
} finally {
|
||
setShareDrawerVisible(false);
|
||
}
|
||
}
|
||
|
||
// ───── Inline-Rating Drawer ─────
|
||
async function submitInlineRating(fb: SosFeedback) {
|
||
inlineFeedbackRef.current = fb;
|
||
setRatingDrawerVisible(false);
|
||
addMessage({
|
||
id: 'rate-ack-' + Date.now(),
|
||
role: 'assistant',
|
||
content: 'Danke für dein Feedback — das hilft mir, besser zu werden. 💛',
|
||
timestamp: new Date(),
|
||
});
|
||
}
|
||
|
||
|
||
async function handleSend() {
|
||
const content = input.trim();
|
||
if (!content || thinking) return;
|
||
setInput('');
|
||
if (chipSet === 'start') setChipSet('help');
|
||
await sendToLyra(content);
|
||
}
|
||
|
||
// ───── Exit + Session-Persist ─────
|
||
function hasInteracted(): boolean {
|
||
const userMsgs = messages.filter((m) => m.role === 'user').length;
|
||
return (
|
||
userMsgs > 0 ||
|
||
breathingCountRef.current > 0 ||
|
||
gamesPlayedRef.current.length > 0 ||
|
||
wasOvercomeRef.current
|
||
);
|
||
}
|
||
|
||
function attemptExit() {
|
||
if (exitingRef.current) return;
|
||
if (isSpeaking) stopSpeaking();
|
||
// Wenn User schon inline bewertet hat → direkt speichern, kein Modal
|
||
if (inlineFeedbackRef.current) {
|
||
exitingRef.current = true;
|
||
void persistSession(inlineFeedbackRef.current).finally(() => router.back());
|
||
return;
|
||
}
|
||
if (hasInteracted()) {
|
||
setFeedbackVisible(true);
|
||
} else {
|
||
// Nur reingeschaut → kein Modal, kein DB-Save
|
||
exitingRef.current = true;
|
||
router.back();
|
||
}
|
||
}
|
||
|
||
async function persistSession(feedback: SosFeedback | null) {
|
||
const endedAt = new Date();
|
||
const durationSec = Math.max(
|
||
1,
|
||
Math.round((endedAt.getTime() - sessionStartRef.current.getTime()) / 1000),
|
||
);
|
||
const payload = {
|
||
startedAt: sessionStartRef.current.toISOString(),
|
||
endedAt: endedAt.toISOString(),
|
||
durationSec,
|
||
messages: messages
|
||
.filter((m) => !m.cardType && m.content)
|
||
.map((m) => ({
|
||
role: m.role,
|
||
content: m.content,
|
||
timestamp: m.timestamp.toISOString(),
|
||
})),
|
||
gamesPlayed: gamesPlayedRef.current,
|
||
breathingCount: breathingCountRef.current,
|
||
wasOvercome: wasOvercomeRef.current,
|
||
feedbackBetter: feedback?.better ?? null,
|
||
feedbackRating: feedback?.rating ?? null,
|
||
feedbackText: feedback?.text || null,
|
||
locale: i18n.language,
|
||
};
|
||
try {
|
||
await apiFetch('/api/sos/session', { method: 'POST', body: payload });
|
||
} catch (err) {
|
||
console.warn('[sos-session-save]', err);
|
||
}
|
||
}
|
||
|
||
async function handleFeedbackSubmit(feedback: SosFeedback) {
|
||
setFeedbackVisible(false);
|
||
exitingRef.current = true;
|
||
await persistSession(feedback);
|
||
router.back();
|
||
}
|
||
|
||
async function handleFeedbackSkip() {
|
||
setFeedbackVisible(false);
|
||
exitingRef.current = true;
|
||
await persistSession(null);
|
||
router.back();
|
||
}
|
||
|
||
const currentChips = dynamicChips.length > 0 ? dynamicChips : (CHIP_SETS[chipSet] ?? []);
|
||
const topBarHeight = insets.top + 160;
|
||
|
||
const renderMessage = useCallback(
|
||
({ item }: { item: SosMsg }) => (
|
||
<MessageRow
|
||
item={item}
|
||
onGameSelect={handleGameSelect}
|
||
onBreathingDone={() => {}}
|
||
/>
|
||
),
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[],
|
||
);
|
||
|
||
const listData: SosMsg[] = messages;
|
||
|
||
function renderItem({ item }: { item: SosMsg }) {
|
||
return <MessageRow item={item} onGameSelect={handleGameSelect} onBreathingDone={() => {}} />;
|
||
}
|
||
|
||
return (
|
||
<SafeAreaView style={st.container} edges={['top']}>
|
||
<View style={[st.topBarBackdrop, { height: topBarHeight }]} pointerEvents="none" />
|
||
|
||
{/* Header */}
|
||
<View style={[st.topBar, { top: insets.top + 6 }]}>
|
||
<Pressable style={st.actionBtn} onPress={attemptExit} hitSlop={12}>
|
||
<Ionicons name="close" size={22} color="#374151" />
|
||
</Pressable>
|
||
<View style={st.avatarCenter}>
|
||
<RiveAvatar emotion={emotion} size="md" />
|
||
<View style={st.avatarMeta}>
|
||
<Text style={st.avatarName}>Lyra · SOS [v2]</Text>
|
||
{(thinking || isLoading) && !isSpeaking && (
|
||
<View style={st.speakingRow}>
|
||
<VoiceBars count={5} baseColor="#3b82f6" />
|
||
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#3b82f6' }}>denkt nach …</Text>
|
||
</View>
|
||
)}
|
||
{!thinking && !isLoading && isTtsLoading && !isSpeaking && (
|
||
<View style={st.speakingRow}>
|
||
<ActivityIndicator size="small" color={colors.brandOrange} />
|
||
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#9ca3af' }}>spricht...</Text>
|
||
</View>
|
||
)}
|
||
{isSpeaking && (
|
||
<View style={st.speakingRow}>
|
||
<VoiceBars count={5} baseColor={colors.brandOrange} />
|
||
<Pressable style={st.stopBtn} onPress={stopSpeaking} hitSlop={6}>
|
||
<Ionicons name="square" size={10} color={colors.brandOrange} />
|
||
</Pressable>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
<Pressable style={st.actionBtn} onPress={() => setSoundEnabled((s) => !s)} hitSlop={12}>
|
||
<Ionicons name={soundEnabled ? 'volume-high' : 'volume-mute-outline'} size={22} color={soundEnabled ? colors.brandOrange : '#9ca3af'} />
|
||
</Pressable>
|
||
</View>
|
||
|
||
<View style={[st.ttsToggleBar, { top: topBarHeight - 36 }]} pointerEvents="box-none">
|
||
<TtsProviderToggle />
|
||
<LlmProviderToggle />
|
||
</View>
|
||
|
||
{playingGame ? (
|
||
<View style={{ flex: 1 }}>
|
||
<GameHeader game={playingGame} emotion={emotion} onBack={handleGameAbandon} />
|
||
<View style={{ flex: 1, padding: 14 }}>
|
||
{playingGame === 'memory' && <MemoryGame onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
|
||
{playingGame === 'tictactoe' && <TicTacToeGame onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
|
||
{playingGame === 'snake' && <SnakeGame onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
|
||
{playingGame === 'tetris' && <TetrisGame onComplete={handleGameComplete} onAbandon={handleGameAbandon} />}
|
||
</View>
|
||
</View>
|
||
) : (
|
||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'} keyboardVerticalOffset={0}>
|
||
{(
|
||
<View style={{ flex: 1 }}>
|
||
<FlatList
|
||
ref={flatRef}
|
||
data={listData}
|
||
renderItem={renderItem as any}
|
||
keyExtractor={(item: any) => item.id}
|
||
contentContainerStyle={[st.listContent, { paddingTop: topBarHeight + 10 }]}
|
||
showsVerticalScrollIndicator={false}
|
||
onScroll={handleScroll}
|
||
scrollEventThrottle={100}
|
||
onContentSizeChange={() => { if (isNearBottomRef.current) flatRef.current?.scrollToEnd({ animated: false }); }}
|
||
ListFooterComponent={null}
|
||
/>
|
||
{showScrollBtn && (
|
||
<Pressable style={st.scrollDownBtn} onPress={() => { flatRef.current?.scrollToEnd({ animated: true }); setShowScrollBtn(false); }}>
|
||
<Ionicons name="chevron-down" size={18} color="#fff" />
|
||
</Pressable>
|
||
)}
|
||
</View>
|
||
)}
|
||
|
||
{/* Chips above input — only after Lyra has answered.
|
||
Bei Standard-Actions (breathing/game/overcome/etc): Ionicons
|
||
(native = SF Symbols iOS, Material Android) + Label OHNE Emoji.
|
||
Bei custom-Actions ohne Mapping: Emoji aus chip.label (LLM-generiert).
|
||
Verhindert "Lung-Emoji + leaf-Icon"-Doppelung. */}
|
||
{currentChips.length > 0 && !isLoading && !thinking && (
|
||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 12, paddingVertical: 8, gap: 8 }}>
|
||
{currentChips.map((chip) => {
|
||
const isOvercome = chip.action === 'overcome';
|
||
const isStats = chip.action === 'show_stats';
|
||
const isFeel = chip.action.startsWith('feel:');
|
||
const isBreathing = chip.action === 'breathing';
|
||
const isGame = chip.action === 'game_picker' || chip.action === 'just_play';
|
||
const isHelp = chip.action === 'need_help';
|
||
const isClose = chip.action === 'close';
|
||
|
||
const iconColor =
|
||
isBreathing ? '#0891b2' :
|
||
isGame ? '#9333ea' :
|
||
isOvercome ? '#16a34a' :
|
||
isStats ? '#2563eb' :
|
||
isHelp ? '#dc2626' :
|
||
isClose ? '#94a3b8' :
|
||
isFeel ? '#7c3aed' :
|
||
'#475569';
|
||
|
||
const iconName: any =
|
||
isBreathing ? 'leaf-outline' :
|
||
isGame ? 'game-controller-outline' :
|
||
isOvercome ? 'checkmark-circle-outline' :
|
||
isStats ? 'stats-chart-outline' :
|
||
isHelp ? 'alert-circle-outline' :
|
||
isClose ? 'close-circle-outline' :
|
||
isFeel ? 'heart-outline' :
|
||
null;
|
||
|
||
// Wenn Ionicons-Match: Emoji aus Label strippen (kein Doppel-Icon)
|
||
const labelText = iconName
|
||
? chip.label.replace(/^\s*[\p{Extended_Pictographic}\p{Emoji_Component}]+\s*/u, '')
|
||
: chip.label;
|
||
|
||
return (
|
||
<Pressable
|
||
key={chip.action}
|
||
onPress={() => handleChip(chip.action)}
|
||
disabled={thinking}
|
||
style={({ pressed }) => [
|
||
st.chip,
|
||
pressed && st.chipPressed,
|
||
thinking && { opacity: 0.4 },
|
||
]}
|
||
>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||
{iconName && <Ionicons name={iconName} size={15} color={iconColor} />}
|
||
<Text style={st.chipText}>{labelText}</Text>
|
||
</View>
|
||
</Pressable>
|
||
);
|
||
})}
|
||
</View>
|
||
)}
|
||
|
||
{/* Input bar — natürliche Höhe, außerhalb flex:1 */}
|
||
<View style={[st.inputBar, { paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom) }]}>
|
||
<TextInput
|
||
style={st.textInput}
|
||
placeholder={t('coach.placeholder')}
|
||
placeholderTextColor="#a3a3a3"
|
||
value={input}
|
||
onChangeText={setInput}
|
||
multiline
|
||
maxLength={1000}
|
||
returnKeyType="send"
|
||
onSubmitEditing={handleSend}
|
||
editable={!thinking}
|
||
/>
|
||
{input.trim() !== '' && (
|
||
<Pressable style={[st.sendBtn, thinking && { opacity: 0.4 }]} onPress={handleSend} disabled={thinking || !input.trim()}>
|
||
<Ionicons name="send" size={16} color="#fff" />
|
||
</Pressable>
|
||
)}
|
||
</View>
|
||
</KeyboardAvoidingView>
|
||
)}
|
||
|
||
{/* Breathing drawer — absolute, slides up over input */}
|
||
{isBreathing && (
|
||
<BreathingDrawer
|
||
onDone={handleBreathingDone}
|
||
onSpeak={speakText}
|
||
/>
|
||
)}
|
||
|
||
{/* Game picker drawer — absolute, slides up over input */}
|
||
{isPickingGame && !playingGame && (
|
||
<GamePickerDrawer
|
||
onSelect={handleGameSelect}
|
||
onClose={() => setIsPickingGame(false)}
|
||
/>
|
||
)}
|
||
|
||
{/* Inline-Rating Drawer (Alternative zum Exit-Modal) */}
|
||
{ratingDrawerVisible && (
|
||
<InlineRatingDrawer
|
||
onSubmit={submitInlineRating}
|
||
onClose={() => setRatingDrawerVisible(false)}
|
||
/>
|
||
)}
|
||
|
||
{/* Share-Success Drawer */}
|
||
{shareDrawerVisible && (
|
||
<ShareSuccessDrawer
|
||
initialText={shareDraft}
|
||
generating={shareGenerating}
|
||
onShare={submitSharePost}
|
||
onClose={() => setShareDrawerVisible(false)}
|
||
onRegenerate={generateShareDraft}
|
||
/>
|
||
)}
|
||
|
||
{/* Exit-Feedback-Modal */}
|
||
<SosFeedbackModal
|
||
visible={feedbackVisible}
|
||
onSubmit={handleFeedbackSubmit}
|
||
onSkip={handleFeedbackSkip}
|
||
/>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const st = StyleSheet.create({
|
||
container: { flex: 1, backgroundColor: '#ffffff' },
|
||
topBar: { position: 'absolute', left: 0, right: 0, zIndex: 10, flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', paddingHorizontal: 12 },
|
||
topBarBackdrop: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 9, backgroundColor: '#ffffff' },
|
||
ttsToggleBar: { position: "absolute", left: 0, right: 0, zIndex: 11, alignItems: "center" },
|
||
actionBtn: { 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: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' },
|
||
speakingRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||
stopBtn: { width: 18, height: 18, borderRadius: 9, backgroundColor: '#f5f5f5', alignItems: 'center', justifyContent: 'center' },
|
||
listContent: { paddingHorizontal: 12, paddingBottom: 4 },
|
||
scrollDownBtn: { position: 'absolute', bottom: 8, right: 16, width: 36, height: 36, borderRadius: 18, backgroundColor: '#374151', alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 4 },
|
||
chip: {
|
||
borderRadius: 14,
|
||
borderWidth: 1.5,
|
||
borderColor: '#9ca3af', // sichtbarer Ring (medium-grau gegen weiß)
|
||
backgroundColor: '#ffffff',
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 11,
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 2 },
|
||
shadowOpacity: 0.12,
|
||
shadowRadius: 6,
|
||
elevation: 3,
|
||
},
|
||
chipPressed: {
|
||
backgroundColor: '#f3f4f6',
|
||
borderColor: '#6b7280', // dunkler beim Press → spürbares Feedback
|
||
transform: [{ scale: 0.97 }],
|
||
shadowOpacity: 0.05,
|
||
},
|
||
chipText: { fontFamily: 'Nunito_600SemiBold', fontSize: 14, color: '#334155' },
|
||
inputBar: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingTop: 8, borderTopWidth: 1, borderTopColor: '#f3f4f6', backgroundColor: '#fff', gap: 8 },
|
||
textInput: { flex: 1, minHeight: 40, maxHeight: 120, backgroundColor: '#f3f4f6', borderRadius: 20, paddingHorizontal: 14, paddingVertical: 10, fontSize: 15, fontFamily: 'Nunito_400Regular', color: '#111827' },
|
||
sendBtn: { width: 38, height: 38, borderRadius: 19, backgroundColor: '#007AFF', alignItems: 'center', justifyContent: 'center' },
|
||
});
|