ARIA-AGENT/android/src/screens/ChatScreen.tsx

977 lines
30 KiB
TypeScript

/**
* 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 RNFS from 'react-native-fs';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService from '../services/wakeword';
import updateService from '../services/updater';
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 (file://) fuer Anzeige
mimeType?: string;
serverPath?: string; // Pfad auf dem Server (/shared/uploads/...) fuer Re-Download
}
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;
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
async function getAttachmentDir(): Promise<string> {
try {
const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY);
return saved || DEFAULT_ATTACHMENT_DIR;
} catch { return DEFAULT_ATTACHMENT_DIR; }
}
/** Speichert Base64-Daten als Datei, gibt file:// Pfad zurueck */
async function persistAttachment(base64Data: string, msgId: string, fileName: string): Promise<string> {
const cacheDir = await getAttachmentDir();
await RNFS.mkdir(cacheDir);
// Dateiendung aus originalem Dateinamen oder Fallback
const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
const safeName = `${msgId}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
const filePath = `${cacheDir}/${safeName}`;
await RNFS.writeFile(filePath, base64Data, 'base64');
return `file://${filePath}`;
}
/** Prueft ob eine lokale Datei noch existiert */
async function checkFileExists(uri: string): Promise<boolean> {
if (!uri || !uri.startsWith('file://')) return false;
return RNFS.exists(uri.replace('file://', ''));
}
// --- Komponente ---
const ChatScreen: React.FC = () => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState('');
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [showFileUpload, setShowFileUpload] = useState(false);
const [showCameraUpload, setShowCameraUpload] = useState(false);
const [gpsEnabled, setGpsEnabled] = useState(false);
const [wakeWordActive, setWakeWordActive] = useState(false);
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false);
const flatListRef = useRef<FlatList>(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
const isInitialLoad = useRef(true);
useEffect(() => {
const loadMessages = async () => {
try {
const stored = await AsyncStorage.getItem(CHAT_STORAGE_KEY);
console.log('[Chat] AsyncStorage geladen:', stored ? `${stored.length} Bytes` : 'leer');
if (stored) {
const parsed: ChatMessage[] = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length > 0) {
console.log('[Chat] ${parsed.length} Nachrichten geladen');
setMessages(parsed);
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);
} finally {
isInitialLoad.current = false;
}
};
loadMessages().then(async () => {
// Auto-Re-Download: fehlende Anhänge vom Server nachladen (wenn aktiviert)
const autoDownload = await AsyncStorage.getItem('aria_auto_download');
if (autoDownload === 'false') return;
setTimeout(() => {
setMessages(prev => {
const missing: {id: string, serverPath: string}[] = [];
for (const msg of prev) {
for (const att of msg.attachments || []) {
if (att.serverPath && !att.uri) {
missing.push({ id: msg.id, serverPath: att.serverPath });
}
}
}
if (missing.length > 0) {
console.log(`[Chat] ${missing.length} fehlende Anhaenge — lade nach...`);
for (const m of missing) {
rvs.send('file_request' as any, { serverPath: m.serverPath, requestId: m.id });
}
}
return prev;
});
}, 2000); // Warten bis RVS verbunden ist
});
}, []);
// RVS-Nachrichten abonnieren
useEffect(() => {
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
// file_saved: Bridge meldet Server-Pfad — in Attachment merken fuer Re-Download
if (message.type === 'file_saved') {
const serverPath = (message.payload.serverPath as string) || '';
const name = (message.payload.name as string) || '';
if (serverPath) {
setMessages(prev => prev.map(m => ({
...m,
attachments: m.attachments?.map(a =>
a.name === name && !a.serverPath ? { ...a, serverPath } : a
),
})));
}
return;
}
// file_response: Re-Download von Server — lokal speichern
if (message.type === 'file_response') {
const reqId = (message.payload.requestId as string) || '';
const b64 = (message.payload.base64 as string) || '';
const serverPath = (message.payload.serverPath as string) || '';
if (b64 && reqId) {
const fileName = (message.payload.name as string) || 'download';
persistAttachment(b64, reqId, fileName).then(filePath => {
setMessages(prev => prev.map(m => ({
...m,
attachments: m.attachments?.map(a =>
a.serverPath === serverPath ? { ...a, uri: filePath } : a
),
})));
}).catch(() => {});
}
return;
}
if (message.type === 'chat') {
const sender = (message.payload.sender as string) || '';
// STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben
if (sender === 'stt') {
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: `\uD83C\uDFA4 ${sttText}` }
: m
));
}
return;
}
// Eigene App-Nachrichten ignorieren (werden lokal hinzugefuegt)
if (sender === 'user') return;
// Diagnostic-Nachrichten als User-Nachricht anzeigen
if (sender === 'diagnostic') {
const diagText = (message.payload.text as string) || '';
if (diagText) {
setMessages(prev => [...prev, {
id: nextId(),
sender: 'user',
text: diagText,
timestamp: message.timestamp,
}]);
}
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();
};
}, []);
// Auto-Update: Bei App-Start pruefen
useEffect(() => {
const unsubUpdate = updateService.onUpdateAvailable((info) => {
updateService.promptUpdate(info);
});
// Nach 5s pruefen (RVS muss erst verbunden sein)
const timer = setTimeout(() => updateService.checkForUpdate(), 5000);
return () => { unsubUpdate(); clearTimeout(timer); };
}, []);
// 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 (debounced, nur nach initialem Laden)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (messages.length === 0 || isInitialLoad.current) return;
// Debounce: 1s warten damit persistAttachment fertig werden kann
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
const toStore = messages.slice(-MAX_STORED_MESSAGES).map(msg => ({
...msg,
attachments: msg.attachments?.map(att => ({
...att,
// Nur file:// URIs speichern, data: URIs rausfiltern (zu gross fuer AsyncStorage)
uri: att.uri?.startsWith('file://') ? att.uri : undefined,
})),
}));
const json = JSON.stringify(toStore);
// Sicherheitscheck: nicht speichern wenn >4MB (AsyncStorage Limit)
if (json.length > 4 * 1024 * 1024) {
console.warn('[Chat] Speicher zu gross, kuerze auf 100 Nachrichten');
const shortened = JSON.stringify(toStore.slice(-100));
AsyncStorage.setItem(CHAT_STORAGE_KEY, shortened).catch(() => {});
} else {
AsyncStorage.setItem(CHAT_STORAGE_KEY, json).catch(err =>
console.error('[Chat] Speichern fehlgeschlagen:', err),
);
}
}, 1000);
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
}, [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 msgId = nextId();
let imageUri = isImage && file.base64 ? `data:${file.type};base64,${file.base64}` : file.uri;
const userMsg: ChatMessage = {
id: msgId,
sender: 'user',
text: 'Anhang empfangen',
timestamp: Date.now(),
attachments: [{
type: isImage ? 'image' : 'file',
name: file.name,
size: file.size,
uri: imageUri,
mimeType: file.type,
}],
};
setMessages(prev => [...prev, userMsg]);
// Anhang auf Disk speichern fuer Persistenz
if (file.base64) {
persistAttachment(file.base64, msgId, file.name).then(filePath => {
setMessages(prev => prev.map(m =>
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m
));
}).catch(() => {});
}
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 msgId = nextId();
const dataUri = photo.base64 ? `data:${photo.type};base64,${photo.base64}` : undefined;
const userMsg: ChatMessage = {
id: msgId,
sender: 'user',
text: 'Anhang empfangen',
timestamp: Date.now(),
attachments: [{
type: 'image',
name: photo.fileName,
uri: dataUri,
mimeType: photo.type,
}],
};
setMessages(prev => [...prev, userMsg]);
// Foto auf Disk speichern fuer Persistenz
if (photo.base64) {
persistAttachment(photo.base64, msgId, photo.fileName).then(filePath => {
setMessages(prev => prev.map(m =>
m.id === msgId ? { ...m, attachments: m.attachments?.map(a => ({ ...a, uri: filePath })) } : m
));
}).catch(() => {});
}
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 (
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble]}>
{/* Anhang-Vorschau */}
{item.attachments?.map((att, idx) => (
<View key={idx}>
{att.type === 'image' && att.uri ? (
<TouchableOpacity onPress={() => setFullscreenImage(att.uri || null)} activeOpacity={0.8}>
<Image
source={{ uri: att.uri }}
style={styles.attachmentImage}
resizeMode="cover"
onError={() => {
setMessages(prev => prev.map(m =>
m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) =>
i === idx ? { ...a, uri: undefined } : a
)} : m
));
}}
/>
</TouchableOpacity>
) : att.type === 'image' && !att.uri ? (
<TouchableOpacity
style={styles.attachmentFile}
onPress={() => {
if (att.serverPath) {
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
}
}}
>
<Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
<Text style={styles.attachmentFileSize}>
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
</Text>
</TouchableOpacity>
) : (
<View style={styles.attachmentFile}>
<Text style={styles.attachmentFileIcon}>
{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'}
</Text>
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
{att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null}
{!att.uri && att.serverPath && (
<TouchableOpacity onPress={() => rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id })}>
<Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(laden)</Text>
</TouchableOpacity>
)}
{!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>}
</View>
)}
</View>
))}
{/* 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)) && (
<Text style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}>
{item.text}
</Text>
)}
{/* Play-Button fuer ARIA-Nachrichten */}
{!isUser && item.text.length > 0 && (
<TouchableOpacity
style={styles.playButton}
onPress={() => {
// TTS-Request an Bridge senden
rvs.send('tts_request' as any, { text: item.text, voice: '' });
}}
>
<Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text>
</TouchableOpacity>
)}
<Text style={styles.timestamp}>{time}</Text>
</View>
);
};
const connectionDotColor =
connectionState === 'connected' ? '#34C759' :
connectionState === 'connecting' ? '#FFD60A' : '#FF3B30';
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={90}
>
{/* Verbindungsstatus-Leiste */}
<View style={styles.statusBar}>
<View style={[styles.statusDot, { backgroundColor: connectionDotColor }]} />
<Text style={styles.statusText}>
{connectionState === 'connected' ? 'Verbunden' :
connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'}
</Text>
<TouchableOpacity onPress={() => setSearchVisible(!searchVisible)} style={{marginLeft: 'auto', paddingHorizontal: 8}}>
<Text style={{fontSize: 16}}>{'\uD83D\uDD0D'}</Text>
</TouchableOpacity>
</View>
{/* Suchleiste */}
{searchVisible && (
<View style={styles.searchBar}>
<TextInput
style={styles.searchInput}
value={searchQuery}
onChangeText={setSearchQuery}
placeholder="Chat durchsuchen..."
placeholderTextColor="#555570"
autoFocus
/>
<TouchableOpacity onPress={() => { setSearchVisible(false); setSearchQuery(''); }}>
<Text style={{color: '#FF3B30', fontSize: 14, paddingHorizontal: 8}}>X</Text>
</TouchableOpacity>
</View>
)}
{/* Nachrichtenliste */}
<FlatList
ref={flatListRef}
data={searchQuery ? messages.filter(m => m.text.toLowerCase().includes(searchQuery.toLowerCase())) : messages}
keyExtractor={item => item.id}
renderItem={renderMessage}
contentContainerStyle={styles.messageList}
showsVerticalScrollIndicator={false}
onContentSizeChange={handleContentSizeChange}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>{'\uD83E\uDD16'}</Text>
<Text style={styles.emptyText}>ARIA Cockpit</Text>
<Text style={styles.emptyHint}>Starte eine Konversation mit ARIA</Text>
</View>
}
/>
{/* Eingabebereich */}
<View style={styles.inputContainer}>
{/* Datei-Buttons */}
<TouchableOpacity
style={styles.actionButton}
onPress={() => setShowFileUpload(true)}
>
<Text style={styles.actionIcon}>{'\uD83D\uDCCE'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionButton}
onPress={() => setShowCameraUpload(true)}
>
<Text style={styles.actionIcon}>{'\uD83D\uDCF7'}</Text>
</TouchableOpacity>
{/* Texteingabe */}
<TextInput
style={styles.textInput}
value={inputText}
onChangeText={setInputText}
placeholder="Nachricht an ARIA..."
placeholderTextColor="#555570"
multiline
maxLength={4000}
onSubmitEditing={sendTextMessage}
returnKeyType="send"
/>
{/* Senden oder Sprache */}
{inputText.trim() ? (
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
</TouchableOpacity>
) : (
<>
<VoiceButton
onRecordingComplete={handleVoiceRecording}
disabled={connectionState !== 'connected'}
wakeWordActive={wakeWordActive}
/>
<TouchableOpacity
style={[styles.wakeWordBtn, wakeWordActive && styles.wakeWordBtnActive]}
onPress={toggleWakeWord}
>
<Text style={styles.wakeWordIcon}>{wakeWordActive ? '👂' : '🔇'}</Text>
</TouchableOpacity>
</>
)}
</View>
{/* Bild-Vollbild Modal */}
<Modal visible={!!fullscreenImage} transparent animationType="fade" onRequestClose={() => setFullscreenImage(null)}>
<TouchableOpacity
style={styles.fullscreenOverlay}
activeOpacity={1}
onPress={() => setFullscreenImage(null)}
>
{fullscreenImage && (
<Image
source={{ uri: fullscreenImage }}
style={styles.fullscreenImage}
resizeMode="contain"
/>
)}
</TouchableOpacity>
</Modal>
{/* Datei-Upload Modal */}
<Modal visible={showFileUpload} transparent animationType="slide">
<View style={styles.modalOverlay}>
<FileUpload
onFileSelected={handleFileSelected}
onCancel={() => setShowFileUpload(false)}
/>
</View>
</Modal>
{/* Kamera-Upload Modal */}
<Modal visible={showCameraUpload} transparent animationType="slide">
<View style={styles.modalOverlay}>
<CameraUpload
onPhotoSelected={handlePhotoSelected}
onCancel={() => setShowCameraUpload(false)}
/>
</View>
</Modal>
</KeyboardAvoidingView>
);
};
// --- 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%',
minHeight: 200,
maxHeight: 400,
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,
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#12122A',
paddingHorizontal: 12,
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
searchInput: {
flex: 1,
color: '#FFFFFF',
fontSize: 14,
paddingVertical: 4,
},
playButton: {
alignSelf: 'flex-end',
paddingHorizontal: 8,
paddingVertical: 2,
marginTop: 4,
},
playButtonText: {
fontSize: 16,
},
fullscreenOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.95)',
justifyContent: 'center',
alignItems: 'center',
},
fullscreenImage: {
width: '100%',
height: '100%',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
},
});
export default ChatScreen;