/** * ChatScreen - Hauptchat-Oberflaeche * * Zeigt die Konversation mit ARIA, Texteingabe, Sprach-Button, * Datei- und Kamera-Upload. */ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { View, Text, TextInput, TouchableOpacity, FlatList, KeyboardAvoidingView, Platform, StyleSheet, Image, Modal, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import audioService from '../services/audio'; import wakeWordService from '../services/wakeword'; import VoiceButton from '../components/VoiceButton'; import FileUpload, { FileData } from '../components/FileUpload'; import CameraUpload, { PhotoData } from '../components/CameraUpload'; import { RecordingResult } from '../services/audio'; import Geolocation from '@react-native-community/geolocation'; // --- Typen --- interface Attachment { type: 'image' | 'file' | 'audio'; name: string; size?: number; uri?: string; // Lokaler Pfad oder data URI fuer Anzeige mimeType?: string; } interface ChatMessage { id: string; sender: 'user' | 'aria'; text: string; timestamp: number; attachments?: Attachment[]; } // --- Konstanten --- const CHAT_STORAGE_KEY = 'aria_chat_messages'; const MAX_STORED_MESSAGES = 500; // --- Komponente --- const ChatScreen: React.FC = () => { const [messages, setMessages] = useState([]); const [inputText, setInputText] = useState(''); const [connectionState, setConnectionState] = useState('disconnected'); const [showFileUpload, setShowFileUpload] = useState(false); const [showCameraUpload, setShowCameraUpload] = useState(false); const [gpsEnabled, setGpsEnabled] = useState(false); const [wakeWordActive, setWakeWordActive] = useState(false); const flatListRef = useRef(null); const messageIdCounter = useRef(0); // Eindeutige Message-ID generieren const nextId = (): string => { messageIdCounter.current += 1; return `msg_${Date.now()}_${messageIdCounter.current}`; }; // Chat-Verlauf aus AsyncStorage laden useEffect(() => { const loadMessages = async () => { try { const stored = await AsyncStorage.getItem(CHAT_STORAGE_KEY); if (stored) { const parsed: ChatMessage[] = JSON.parse(stored); setMessages(parsed); // ID-Counter auf hoechsten Wert setzen um Kollisionen zu vermeiden const maxId = parsed.reduce((max, msg) => { const num = parseInt(msg.id.split('_').pop() || '0', 10); return num > max ? num : max; }, 0); messageIdCounter.current = maxId; } } catch (err) { console.error('[Chat] Fehler beim Laden des Verlaufs:', err); } }; loadMessages(); }, []); // RVS-Nachrichten abonnieren useEffect(() => { const unsubMessage = rvs.onMessage((message: RVSMessage) => { // STT-Ergebnis: Spracheingabe-Placeholder mit transkribiertem Text ersetzen if (message.type === 'stt_result') { const sttText = (message.payload.text as string) || ''; if (sttText) { setMessages(prev => prev.map(m => m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet') ? { ...m, text: sttText } : m )); } else { // Keine Sprache erkannt — Placeholder entfernen setMessages(prev => prev.filter(m => !(m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')) )); } return; } if (message.type === 'chat') { // Nur Nachrichten von ARIA anzeigen — eigene Nachrichten werden lokal hinzugefuegt const sender = (message.payload.sender as string) || ''; if (sender === 'user' || sender === 'diagnostic') return; const text = (message.payload.text as string) || ''; const ts = message.timestamp; // Duplikat-Schutz: gleicher Text innerhalb 5s ignorieren setMessages(prev => { const isDuplicate = prev.some(m => m.sender === 'aria' && m.text === text && Math.abs(m.timestamp - ts) < 5000 ); if (isDuplicate) return prev; const ariaMsg: ChatMessage = { id: nextId(), sender: 'aria', text, timestamp: ts, attachments: message.payload.attachments as Attachment[] | undefined, }; return [...prev, ariaMsg]; }); } // TTS-Audio abspielen wenn vorhanden if (message.type === 'audio' && message.payload.base64) { audioService.playAudio(message.payload.base64 as string); } }); const unsubState = rvs.onStateChange((state) => { setConnectionState(state); }); // Initalen Status setzen setConnectionState(rvs.getState()); return () => { unsubMessage(); unsubState(); }; }, []); // Wake Word: "ARIA" Erkennung → Auto-Aufnahme starten useEffect(() => { const unsubWake = wakeWordService.onWakeWord(async () => { console.log('[Chat] Wake Word erkannt — starte Auto-Aufnahme'); // TTS stoppen damit ARIA sich nicht selbst hoert audioService.stopPlayback(); // Aufnahme mit Auto-Stop (VAD) starten const started = await audioService.startRecording(true); if (!started) { // Mikrofon nicht verfuegbar, Wake Word wieder aktivieren wakeWordService.resume(); } }); // Auto-Stop Callback: wenn Stille erkannt → Aufnahme senden + Wake Word wieder starten const unsubSilence = audioService.onSilenceDetected(async () => { const result = await audioService.stopRecording(); if (result && result.durationMs > 500) { // Sprachnachricht senden (gleiche Logik wie handleVoiceRecording) const location = await getCurrentLocation(); const userMsg: ChatMessage = { id: nextId(), sender: 'user', text: '🎙 Spracheingabe wird verarbeitet...', timestamp: Date.now(), attachments: [{ type: 'audio', name: 'Sprachaufnahme' }], }; setMessages(prev => [...prev, userMsg]); rvs.send('audio', { base64: result.base64, durationMs: result.durationMs, mimeType: result.mimeType, ...(location && { location }), }); } // Wake Word wieder aktivieren if (wakeWordActive) wakeWordService.resume(); }); return () => { unsubWake(); unsubSilence(); }; }, [wakeWordActive]); // Wake Word Toggle Handler const toggleWakeWord = useCallback(async () => { if (wakeWordActive) { wakeWordService.stop(); setWakeWordActive(false); } else { const started = await wakeWordService.start(); setWakeWordActive(started); } }, [wakeWordActive]); // Chat-Verlauf in AsyncStorage speichern (letzte N Nachrichten) useEffect(() => { if (messages.length === 0) return; const toStore = messages.slice(-MAX_STORED_MESSAGES); AsyncStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(toStore)).catch(err => console.error('[Chat] Fehler beim Speichern:', err), ); }, [messages]); // Auto-Scroll wird ueber onContentSizeChange der FlatList gesteuert const shouldAutoScroll = useRef(true); const handleContentSizeChange = useCallback(() => { if (shouldAutoScroll.current) { flatListRef.current?.scrollToEnd({ animated: false }); } }, []); const handleScrollBeginDrag = useCallback(() => { shouldAutoScroll.current = false; }, []); const handleScrollEndDrag = useCallback((e: any) => { // Auto-Scroll wieder aktivieren wenn User ganz unten ist const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; const isAtBottom = contentOffset.y + layoutMeasurement.height >= contentSize.height - 50; shouldAutoScroll.current = isAtBottom; }, []); // GPS-Position holen (optional) const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => { if (!gpsEnabled) return Promise.resolve(null); return new Promise((resolve) => { Geolocation.getCurrentPosition( (position) => { resolve({ lat: position.coords.latitude, lon: position.coords.longitude, }); }, (_error) => { resolve(null); }, { enableHighAccuracy: false, timeout: 5000 }, ); }); }, [gpsEnabled]); // --- Nachricht senden --- const sendTextMessage = useCallback(async () => { const text = inputText.trim(); if (!text) return; setInputText(''); const location = await getCurrentLocation(); const userMsg: ChatMessage = { id: nextId(), sender: 'user', text, timestamp: Date.now(), }; setMessages(prev => [...prev, userMsg]); // An RVS senden rvs.send('chat', { text, ...(location && { location }), }); }, [inputText, getCurrentLocation]); // Sprachaufnahme abgeschlossen const handleVoiceRecording = useCallback(async (result: RecordingResult) => { const location = await getCurrentLocation(); const userMsg: ChatMessage = { id: nextId(), sender: 'user', text: '🎙 Spracheingabe wird verarbeitet...', timestamp: Date.now(), }; setMessages(prev => [...prev, userMsg]); rvs.send('audio', { base64: result.base64, durationMs: result.durationMs, mimeType: result.mimeType, ...(location && { location }), }); }, [getCurrentLocation]); // Datei senden const handleFileSelected = useCallback(async (file: FileData) => { setShowFileUpload(false); const location = await getCurrentLocation(); const isImage = file.type.startsWith('image/'); const userMsg: ChatMessage = { id: nextId(), sender: 'user', text: 'Anhang empfangen', timestamp: Date.now(), attachments: [{ type: isImage ? 'image' : 'file', name: file.name, size: file.size, uri: isImage && file.base64 ? `data:${file.type};base64,${file.base64}` : file.uri, mimeType: file.type, }], }; setMessages(prev => [...prev, userMsg]); rvs.send('file', { name: file.name, type: file.type, size: file.size, base64: file.base64, ...(location && { location }), }); }, [getCurrentLocation]); // Foto senden const handlePhotoSelected = useCallback(async (photo: PhotoData) => { setShowCameraUpload(false); const location = await getCurrentLocation(); const userMsg: ChatMessage = { id: nextId(), sender: 'user', text: 'Anhang empfangen', timestamp: Date.now(), attachments: [{ type: 'image', name: photo.fileName, uri: photo.base64 ? `data:${photo.type};base64,${photo.base64}` : undefined, mimeType: photo.type, }], }; setMessages(prev => [...prev, userMsg]); rvs.send('file', { name: photo.fileName, type: photo.type, base64: photo.base64, width: photo.width, height: photo.height, ...(location && { location }), }); }, [getCurrentLocation]); // --- Rendering --- const renderMessage = ({ item }: { item: ChatMessage }) => { const isUser = item.sender === 'user'; const time = new Date(item.timestamp).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', }); return ( {/* Anhang-Vorschau */} {item.attachments?.map((att, idx) => ( {att.type === 'image' && att.uri ? ( ) : ( {att.mimeType?.includes('pdf') ? '\uD83D\uDCC4' : att.mimeType?.includes('word') || att.mimeType?.includes('document') ? '\uD83D\uDCC3' : att.mimeType?.includes('sheet') || att.mimeType?.includes('excel') ? '\uD83D\uDCC8' : '\uD83D\uDCC1'} {att.name} {att.size ? {Math.round(att.size / 1024)}KB : null} )} ))} {/* Text (nicht anzeigen wenn nur "Anhang empfangen" und ein Bild da ist) */} {!(item.text === 'Anhang empfangen' && item.attachments?.some(a => a.type === 'image' && a.uri)) && ( {item.text} )} {time} ); }; const connectionDotColor = connectionState === 'connected' ? '#34C759' : connectionState === 'connecting' ? '#FFD60A' : '#FF3B30'; return ( {/* Verbindungsstatus-Leiste */} {connectionState === 'connected' ? 'Verbunden' : connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'} {/* Nachrichtenliste */} item.id} renderItem={renderMessage} contentContainerStyle={styles.messageList} showsVerticalScrollIndicator={false} onContentSizeChange={handleContentSizeChange} onScrollBeginDrag={handleScrollBeginDrag} onScrollEndDrag={handleScrollEndDrag} ListEmptyComponent={ {'\uD83E\uDD16'} ARIA Cockpit Starte eine Konversation mit ARIA } /> {/* Eingabebereich */} {/* Datei-Buttons */} setShowFileUpload(true)} > {'\uD83D\uDCCE'} setShowCameraUpload(true)} > {'\uD83D\uDCF7'} {/* Texteingabe */} {/* Senden oder Sprache */} {inputText.trim() ? ( {'\u2B06\uFE0F'} ) : ( <> {wakeWordActive ? '👂' : '🔇'} )} {/* Datei-Upload Modal */} setShowFileUpload(false)} /> {/* Kamera-Upload Modal */} setShowCameraUpload(false)} /> ); }; // --- Styles --- const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#0D0D1A', }, statusBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 8, backgroundColor: '#12122A', borderBottomWidth: 1, borderBottomColor: '#1E1E2E', }, statusDot: { width: 8, height: 8, borderRadius: 4, marginRight: 8, }, statusText: { color: '#8888AA', fontSize: 12, }, messageList: { padding: 12, paddingBottom: 8, flexGrow: 1, }, messageBubble: { maxWidth: '80%', padding: 12, borderRadius: 16, marginBottom: 8, }, userBubble: { alignSelf: 'flex-end', backgroundColor: '#0096FF', borderBottomRightRadius: 4, }, ariaBubble: { alignSelf: 'flex-start', backgroundColor: '#1E1E2E', borderBottomLeftRadius: 4, }, messageText: { fontSize: 15, lineHeight: 21, }, userText: { color: '#FFFFFF', }, ariaText: { color: '#E0E0F0', }, attachmentImage: { width: '100%', height: 200, borderRadius: 8, marginBottom: 6, backgroundColor: '#0D0D1A', }, attachmentFile: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: 8, padding: 10, marginBottom: 6, }, attachmentFileIcon: { fontSize: 24, marginRight: 8, }, attachmentFileName: { flex: 1, color: '#E0E0F0', fontSize: 13, }, attachmentFileSize: { color: '#8888AA', fontSize: 11, marginLeft: 8, }, timestamp: { color: 'rgba(255,255,255,0.4)', fontSize: 10, marginTop: 4, alignSelf: 'flex-end', }, emptyContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingTop: 120, }, emptyIcon: { fontSize: 48, marginBottom: 12, }, emptyText: { color: '#FFFFFF', fontSize: 22, fontWeight: '700', }, emptyHint: { color: '#555570', fontSize: 14, marginTop: 4, }, inputContainer: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 10, paddingVertical: 8, backgroundColor: '#12122A', borderTopWidth: 1, borderTopColor: '#1E1E2E', }, actionButton: { width: 38, height: 38, borderRadius: 19, alignItems: 'center', justifyContent: 'center', marginRight: 4, }, actionIcon: { fontSize: 20, }, textInput: { flex: 1, backgroundColor: '#1E1E2E', borderRadius: 20, paddingHorizontal: 16, paddingVertical: 10, color: '#FFFFFF', fontSize: 15, maxHeight: 100, marginHorizontal: 6, }, sendButton: { width: 40, height: 40, borderRadius: 20, backgroundColor: '#0096FF', alignItems: 'center', justifyContent: 'center', }, sendIcon: { fontSize: 18, }, wakeWordBtn: { width: 32, height: 32, borderRadius: 16, backgroundColor: 'rgba(255,255,255,0.1)', alignItems: 'center', justifyContent: 'center', marginLeft: 4, }, wakeWordBtnActive: { backgroundColor: 'rgba(52, 199, 89, 0.3)', }, wakeWordIcon: { fontSize: 16, }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', }, }); export default ChatScreen;