first release 0.0.0.2
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* CameraUpload - Kamera-Foto oder Galerie-Auswahl
|
||||
*
|
||||
* Ermoeglicht das Aufnehmen eines Fotos mit der Geraetekamera
|
||||
* oder die Auswahl aus der Galerie, mit Vorschau vor dem Senden.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
PermissionsAndroid,
|
||||
} from 'react-native';
|
||||
import { launchCamera, launchImageLibrary, ImagePickerResponse } from 'react-native-image-picker';
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
export interface PhotoData {
|
||||
base64: string;
|
||||
width: number;
|
||||
height: number;
|
||||
fileName: string;
|
||||
type: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
interface CameraUploadProps {
|
||||
onPhotoSelected: (photo: PhotoData) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Komprimierungsoptionen
|
||||
const IMAGE_OPTIONS = {
|
||||
mediaType: 'photo' as const,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1920,
|
||||
quality: 0.8 as const,
|
||||
includeBase64: true,
|
||||
};
|
||||
|
||||
// --- Komponente ---
|
||||
|
||||
const CameraUpload: React.FC<CameraUploadProps> = ({ onPhotoSelected, onCancel }) => {
|
||||
const [preview, setPreview] = useState<ImagePickerResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
/** Kamera-Berechtigung pruefen (Android) */
|
||||
const requestCameraPermission = async (): Promise<boolean> => {
|
||||
if (Platform.OS !== 'android') return true;
|
||||
|
||||
try {
|
||||
const granted = await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.CAMERA,
|
||||
{
|
||||
title: 'ARIA Cockpit - Kamera',
|
||||
message: 'ARIA ben\u00F6tigt Zugriff auf die Kamera.',
|
||||
buttonPositive: 'Erlauben',
|
||||
buttonNegative: 'Ablehnen',
|
||||
},
|
||||
);
|
||||
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Foto mit Kamera aufnehmen */
|
||||
const takePhoto = async () => {
|
||||
const hasPermission = await requestCameraPermission();
|
||||
if (!hasPermission) return;
|
||||
|
||||
launchCamera(IMAGE_OPTIONS, (response) => {
|
||||
if (response.didCancel) {
|
||||
// Benutzer hat abgebrochen
|
||||
return;
|
||||
}
|
||||
if (response.errorCode) {
|
||||
console.error('[CameraUpload] Kamera-Fehler:', response.errorMessage);
|
||||
return;
|
||||
}
|
||||
setPreview(response);
|
||||
});
|
||||
};
|
||||
|
||||
/** Foto aus Galerie auswaehlen */
|
||||
const pickFromGallery = async () => {
|
||||
launchImageLibrary(IMAGE_OPTIONS, (response) => {
|
||||
if (response.didCancel) return;
|
||||
if (response.errorCode) {
|
||||
console.error('[CameraUpload] Galerie-Fehler:', response.errorMessage);
|
||||
return;
|
||||
}
|
||||
setPreview(response);
|
||||
});
|
||||
};
|
||||
|
||||
/** Ausgewaehltes Foto senden */
|
||||
const sendPhoto = () => {
|
||||
const asset = preview?.assets?.[0];
|
||||
if (!asset) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const photoData: PhotoData = {
|
||||
base64: asset.base64 || '',
|
||||
width: asset.width || 0,
|
||||
height: asset.height || 0,
|
||||
fileName: asset.fileName || `foto_${Date.now()}.jpg`,
|
||||
type: asset.type || 'image/jpeg',
|
||||
uri: asset.uri || '',
|
||||
};
|
||||
|
||||
onPhotoSelected(photoData);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const previewUri = preview?.assets?.[0]?.uri;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{!preview ? (
|
||||
// Auswahl: Kamera oder Galerie
|
||||
<View style={styles.optionsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.optionButton}
|
||||
onPress={takePhoto}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.optionIcon}>{'\uD83D\uDCF7'}</Text>
|
||||
<Text style={styles.optionText}>Foto aufnehmen</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.optionButton}
|
||||
onPress={pickFromGallery}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.optionIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
|
||||
<Text style={styles.optionText}>Aus Galerie</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.cancelLink} onPress={onCancel}>
|
||||
<Text style={styles.cancelLinkText}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
// Vorschau
|
||||
<View style={styles.previewContainer}>
|
||||
{previewUri && (
|
||||
<Image source={{ uri: previewUri }} style={styles.imagePreview} />
|
||||
)}
|
||||
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.retakeButton}
|
||||
onPress={() => setPreview(null)}
|
||||
>
|
||||
<Text style={styles.retakeText}>Neu</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.sendButton}
|
||||
onPress={sendPhoto}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#FFFFFF" size="small" />
|
||||
) : (
|
||||
<Text style={styles.sendText}>Senden</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
margin: 12,
|
||||
},
|
||||
optionsContainer: {
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
optionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#2A2A3E',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
width: '100%',
|
||||
},
|
||||
optionIcon: {
|
||||
fontSize: 28,
|
||||
marginRight: 14,
|
||||
},
|
||||
optionText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
cancelLink: {
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
},
|
||||
cancelLinkText: {
|
||||
color: '#666680',
|
||||
fontSize: 14,
|
||||
},
|
||||
previewContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
imagePreview: {
|
||||
width: '100%',
|
||||
height: 280,
|
||||
borderRadius: 12,
|
||||
resizeMode: 'contain',
|
||||
marginBottom: 16,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
retakeButton: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#2A2A3E',
|
||||
},
|
||||
retakeText: {
|
||||
color: '#8888AA',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
sendButton: {
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#0096FF',
|
||||
minWidth: 100,
|
||||
alignItems: 'center',
|
||||
},
|
||||
sendText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export default CameraUpload;
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* FileUpload - Datei-Auswahl und -Versand
|
||||
*
|
||||
* Oeffnet den Dateimanager des Geraets, zeigt eine Vorschau
|
||||
* und konvertiert die Datei zu Base64 fuer die Uebertragung.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import DocumentPicker, {
|
||||
DocumentPickerResponse,
|
||||
} from 'react-native-document-picker';
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
export interface FileData {
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
base64: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
interface FileUploadProps {
|
||||
onFileSelected: (file: FileData) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Unterstuetzte Dateitypen
|
||||
const SUPPORTED_TYPES = [
|
||||
DocumentPicker.types.images,
|
||||
DocumentPicker.types.pdf,
|
||||
DocumentPicker.types.docx,
|
||||
DocumentPicker.types.plainText,
|
||||
];
|
||||
|
||||
// --- Komponente ---
|
||||
|
||||
const FileUpload: React.FC<FileUploadProps> = ({ onFileSelected, onCancel }) => {
|
||||
const [selectedFile, setSelectedFile] = useState<DocumentPickerResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pickFile = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await DocumentPicker.pick({
|
||||
type: SUPPORTED_TYPES,
|
||||
copyTo: 'cachesDirectory',
|
||||
});
|
||||
|
||||
if (result.length > 0) {
|
||||
setSelectedFile(result[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
if (DocumentPicker.isCancel(err)) {
|
||||
onCancel();
|
||||
} else {
|
||||
setError('Fehler beim Auswaehlen der Datei');
|
||||
console.error('[FileUpload] Fehler:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendFile = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// In Produktion: Datei lesen und zu Base64 konvertieren
|
||||
// const base64 = await RNFS.readFile(selectedFile.fileCopyUri || selectedFile.uri, 'base64');
|
||||
const base64Placeholder = '';
|
||||
|
||||
const fileData: FileData = {
|
||||
name: selectedFile.name || 'unbenannt',
|
||||
type: selectedFile.type || 'application/octet-stream',
|
||||
size: selectedFile.size || 0,
|
||||
base64: base64Placeholder,
|
||||
uri: selectedFile.uri,
|
||||
};
|
||||
|
||||
onFileSelected(fileData);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Verarbeiten der Datei');
|
||||
console.error('[FileUpload] Verarbeitungsfehler:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isImage = selectedFile?.type?.startsWith('image/');
|
||||
const fileSizeFormatted = selectedFile?.size
|
||||
? selectedFile.size > 1024 * 1024
|
||||
? `${(selectedFile.size / (1024 * 1024)).toFixed(1)} MB`
|
||||
: `${(selectedFile.size / 1024).toFixed(0)} KB`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{!selectedFile ? (
|
||||
// Datei auswaehlen
|
||||
<TouchableOpacity style={styles.pickButton} onPress={pickFile} activeOpacity={0.7}>
|
||||
<Text style={styles.pickIcon}>{'\uD83D\uDCC1'}</Text>
|
||||
<Text style={styles.pickText}>Datei ausw\u00E4hlen</Text>
|
||||
<Text style={styles.pickHint}>JPG, PNG, PDF, DOCX, TXT</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
// Vorschau und Senden
|
||||
<View style={styles.previewContainer}>
|
||||
{isImage ? (
|
||||
<Image source={{ uri: selectedFile.uri }} style={styles.imagePreview} />
|
||||
) : (
|
||||
<View style={styles.filePreview}>
|
||||
<Text style={styles.fileIcon}>{'\uD83D\uDCC4'}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
{selectedFile.name}
|
||||
</Text>
|
||||
<Text style={styles.fileSize}>{fileSizeFormatted}</Text>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={() => setSelectedFile(null)}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Andere Datei</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.sendButton}
|
||||
onPress={sendFile}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#FFFFFF" size="small" />
|
||||
) : (
|
||||
<Text style={styles.sendButtonText}>Senden</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
margin: 12,
|
||||
},
|
||||
pickButton: {
|
||||
alignItems: 'center',
|
||||
padding: 30,
|
||||
borderWidth: 2,
|
||||
borderColor: '#2A2A3E',
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: 12,
|
||||
},
|
||||
pickIcon: {
|
||||
fontSize: 40,
|
||||
marginBottom: 10,
|
||||
},
|
||||
pickText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
pickHint: {
|
||||
color: '#666680',
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
previewContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
imagePreview: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
filePreview: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#2A2A3E',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
fileIcon: {
|
||||
fontSize: 36,
|
||||
},
|
||||
fileName: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
maxWidth: 250,
|
||||
},
|
||||
fileSize: {
|
||||
color: '#666680',
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
errorText: {
|
||||
color: '#FF3B30',
|
||||
fontSize: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 16,
|
||||
gap: 12,
|
||||
},
|
||||
cancelButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#2A2A3E',
|
||||
},
|
||||
cancelButtonText: {
|
||||
color: '#8888AA',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
sendButton: {
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#0096FF',
|
||||
minWidth: 90,
|
||||
alignItems: 'center',
|
||||
},
|
||||
sendButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export default FileUpload;
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* ModeSelector - Modus-Auswahl fuer ARIA
|
||||
*
|
||||
* Zeigt den aktuellen Betriebsmodus an und ermoeglicht das Umschalten
|
||||
* ueber ein Modal-Dropdown.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import rvs from '../services/rvs';
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
export interface Mode {
|
||||
id: string;
|
||||
label: string;
|
||||
emoji: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ModeSelectorProps {
|
||||
currentModeId: string;
|
||||
onModeChange: (modeId: string) => void;
|
||||
}
|
||||
|
||||
// --- Verfuegbare Modi ---
|
||||
|
||||
export const MODES: Mode[] = [
|
||||
{
|
||||
id: 'normal',
|
||||
label: 'Normal',
|
||||
emoji: '\uD83D\uDFE2',
|
||||
description: 'Standardmodus - ARIA reagiert auf alle Eingaben',
|
||||
},
|
||||
{
|
||||
id: 'nicht_stoeren',
|
||||
label: 'Nicht st\u00F6ren',
|
||||
emoji: '\uD83D\uDD34',
|
||||
description: 'Nur kritische Benachrichtigungen',
|
||||
},
|
||||
{
|
||||
id: 'fluester',
|
||||
label: 'Fl\u00FCster',
|
||||
emoji: '\uD83D\uDFE1',
|
||||
description: 'Leise Antworten, reduzierte Aktivit\u00E4t',
|
||||
},
|
||||
{
|
||||
id: 'hangar',
|
||||
label: 'Hangar',
|
||||
emoji: '\u2708\uFE0F',
|
||||
description: 'Flugmodus - minimale Kommunikation',
|
||||
},
|
||||
{
|
||||
id: 'gaming',
|
||||
label: 'Gaming',
|
||||
emoji: '\uD83C\uDFAE',
|
||||
description: 'Spielmodus - nur dringende Meldungen',
|
||||
},
|
||||
];
|
||||
|
||||
// --- Komponente ---
|
||||
|
||||
const ModeSelector: React.FC<ModeSelectorProps> = ({ currentModeId, onModeChange }) => {
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
|
||||
const currentMode = MODES.find(m => m.id === currentModeId) || MODES[0];
|
||||
|
||||
const handleSelectMode = (mode: Mode) => {
|
||||
setModalVisible(false);
|
||||
onModeChange(mode.id);
|
||||
|
||||
// Moduswechsel an ARIA senden
|
||||
rvs.send('mode', { mode: mode.id });
|
||||
};
|
||||
|
||||
const renderModeItem = ({ item }: { item: Mode }) => {
|
||||
const isActive = item.id === currentModeId;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.modeItem, isActive && styles.modeItemActive]}
|
||||
onPress={() => handleSelectMode(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.modeEmoji}>{item.emoji}</Text>
|
||||
<View style={styles.modeTextContainer}>
|
||||
<Text style={[styles.modeLabel, isActive && styles.modeLabelActive]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text style={styles.modeDescription}>{item.description}</Text>
|
||||
</View>
|
||||
{isActive && <Text style={styles.checkmark}>{'\u2713'}</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
{/* Aktueller Modus - Tappen zum Oeffnen */}
|
||||
<TouchableOpacity
|
||||
style={styles.currentMode}
|
||||
onPress={() => setModalVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.currentEmoji}>{currentMode.emoji}</Text>
|
||||
<Text style={styles.currentLabel}>{currentMode.label}</Text>
|
||||
<Text style={styles.chevron}>{'\u25BC'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Modus-Auswahl Modal */}
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>Modus w\u00E4hlen</Text>
|
||||
<FlatList
|
||||
data={MODES}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderModeItem}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={() => setModalVisible(false)}
|
||||
>
|
||||
<Text style={styles.cancelText}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
currentMode: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A2A3E',
|
||||
},
|
||||
currentEmoji: {
|
||||
fontSize: 22,
|
||||
marginRight: 10,
|
||||
},
|
||||
currentLabel: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
chevron: {
|
||||
color: '#8888AA',
|
||||
fontSize: 12,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
padding: 20,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
modalTitle: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
modeItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
marginBottom: 6,
|
||||
},
|
||||
modeItemActive: {
|
||||
backgroundColor: 'rgba(0, 150, 255, 0.15)',
|
||||
},
|
||||
modeEmoji: {
|
||||
fontSize: 26,
|
||||
marginRight: 14,
|
||||
},
|
||||
modeTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
modeLabel: {
|
||||
color: '#CCCCDD',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
modeLabelActive: {
|
||||
color: '#0096FF',
|
||||
fontWeight: '700',
|
||||
},
|
||||
modeDescription: {
|
||||
color: '#666680',
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
checkmark: {
|
||||
color: '#0096FF',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginLeft: 8,
|
||||
},
|
||||
cancelButton: {
|
||||
marginTop: 12,
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#2A2A3E',
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelText: {
|
||||
color: '#8888AA',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default ModeSelector;
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* VoiceButton - Push-to-Talk Aufnahmeknopf
|
||||
*
|
||||
* Grosser runder Button: gedrueckt halten zum Aufnehmen, loslassen zum Senden.
|
||||
* Visuelles Feedback durch pulsierende Animation waehrend der Aufnahme.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Animated,
|
||||
StyleSheet,
|
||||
GestureResponderEvent,
|
||||
Easing,
|
||||
} from 'react-native';
|
||||
import audioService, { RecordingResult } from '../services/audio';
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
interface VoiceButtonProps {
|
||||
/** Wird aufgerufen wenn die Aufnahme fertig ist */
|
||||
onRecordingComplete: (result: RecordingResult) => void;
|
||||
/** Button deaktivieren */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// --- Komponente ---
|
||||
|
||||
const VoiceButton: React.FC<VoiceButtonProps> = ({ onRecordingComplete, disabled = false }) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [durationMs, setDurationMs] = useState(0);
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Puls-Animation starten/stoppen
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
const pulse = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.2,
|
||||
duration: 600,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
pulse.start();
|
||||
return () => pulse.stop();
|
||||
} else {
|
||||
pulseAnim.setValue(1);
|
||||
}
|
||||
}, [isRecording, pulseAnim]);
|
||||
|
||||
// Aufnahmedauer zaehlen
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
setDurationMs(0);
|
||||
durationTimer.current = setInterval(() => {
|
||||
setDurationMs(prev => prev + 100);
|
||||
}, 100);
|
||||
} else {
|
||||
if (durationTimer.current) {
|
||||
clearInterval(durationTimer.current);
|
||||
durationTimer.current = null;
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (durationTimer.current) {
|
||||
clearInterval(durationTimer.current);
|
||||
}
|
||||
};
|
||||
}, [isRecording]);
|
||||
|
||||
const handlePressIn = async (_event: GestureResponderEvent) => {
|
||||
if (disabled) return;
|
||||
const started = await audioService.startRecording();
|
||||
if (started) {
|
||||
setIsRecording(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePressOut = async (_event: GestureResponderEvent) => {
|
||||
if (!isRecording) return;
|
||||
setIsRecording(false);
|
||||
|
||||
const result = await audioService.stopRecording();
|
||||
if (result && result.durationMs > 300) {
|
||||
// Nur senden wenn laenger als 300ms (versehentliches Tippen vermeiden)
|
||||
onRecordingComplete(result);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const tenths = Math.floor((ms % 1000) / 100);
|
||||
return `${seconds}.${tenths}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.buttonOuter,
|
||||
isRecording && styles.buttonOuterRecording,
|
||||
{ transform: [{ scale: pulseAnim }] },
|
||||
]}
|
||||
onStartShouldSetResponder={() => true}
|
||||
onResponderGrant={handlePressIn}
|
||||
onResponderRelease={handlePressOut}
|
||||
onResponderTerminate={handlePressOut}
|
||||
>
|
||||
<View style={[styles.buttonInner, isRecording && styles.buttonInnerRecording]}>
|
||||
<Text style={styles.buttonIcon}>{isRecording ? '⏹' : '🎙'}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
{isRecording && (
|
||||
<Text style={styles.durationText}>{formatDuration(durationMs)}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonOuter: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: 'rgba(0, 150, 255, 0.2)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonOuterRecording: {
|
||||
backgroundColor: 'rgba(255, 59, 48, 0.3)',
|
||||
},
|
||||
buttonInner: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
backgroundColor: '#0096FF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 4,
|
||||
shadowColor: '#0096FF',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
buttonInnerRecording: {
|
||||
backgroundColor: '#FF3B30',
|
||||
},
|
||||
buttonIcon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
durationText: {
|
||||
color: '#FF3B30',
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
});
|
||||
|
||||
export default VoiceButton;
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* 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,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||
import audioService from '../services/audio';
|
||||
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;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
sender: 'user' | 'aria';
|
||||
text: string;
|
||||
timestamp: number;
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
// --- 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 flatListRef = useRef<FlatList>(null);
|
||||
const messageIdCounter = useRef(0);
|
||||
|
||||
// Eindeutige Message-ID generieren
|
||||
const nextId = (): string => {
|
||||
messageIdCounter.current += 1;
|
||||
return `msg_${Date.now()}_${messageIdCounter.current}`;
|
||||
};
|
||||
|
||||
// GPS-Einstellung aus Settings laden (vereinfacht)
|
||||
useEffect(() => {
|
||||
// In Produktion: AsyncStorage oder Context verwenden
|
||||
// Hier Platzhalter - GPS Toggle kommt aus SettingsScreen
|
||||
}, []);
|
||||
|
||||
// RVS-Nachrichten abonnieren
|
||||
useEffect(() => {
|
||||
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
|
||||
if (message.type === 'chat') {
|
||||
const ariaMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'aria',
|
||||
text: (message.payload.text as string) || '',
|
||||
timestamp: message.timestamp,
|
||||
attachments: message.payload.attachments as Attachment[] | undefined,
|
||||
};
|
||||
setMessages(prev => [...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-Scroll bei neuen Nachrichten
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
setTimeout(() => {
|
||||
flatListRef.current?.scrollToEnd({ animated: true });
|
||||
}, 100);
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// 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: '[Sprachnachricht]',
|
||||
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 }),
|
||||
});
|
||||
}, [getCurrentLocation]);
|
||||
|
||||
// Datei senden
|
||||
const handleFileSelected = useCallback(async (file: FileData) => {
|
||||
setShowFileUpload(false);
|
||||
const location = await getCurrentLocation();
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text: `[Datei: ${file.name}]`,
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'file', name: file.name, size: file.size }],
|
||||
};
|
||||
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: `[Foto: ${photo.fileName}]`,
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'image', name: photo.fileName }],
|
||||
};
|
||||
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 (
|
||||
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble]}>
|
||||
<Text style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}>
|
||||
{item.text}
|
||||
</Text>
|
||||
{item.attachments?.map((att, idx) => (
|
||||
<View key={idx} style={styles.attachmentBadge}>
|
||||
<Text style={styles.attachmentText}>
|
||||
{att.type === 'image' ? '\uD83D\uDDBC\uFE0F' : att.type === 'audio' ? '\uD83C\uDFA4' : '\uD83D\uDCC4'} {att.name}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
<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>
|
||||
</View>
|
||||
|
||||
{/* Nachrichtenliste */}
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderMessage}
|
||||
contentContainerStyle={styles.messageList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
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'}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 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',
|
||||
},
|
||||
attachmentBadge: {
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
marginTop: 6,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
attachmentText: {
|
||||
color: '#CCCCDD',
|
||||
fontSize: 12,
|
||||
},
|
||||
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,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default ChatScreen;
|
||||
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* SettingsScreen - Einstellungen und Verbindungsverwaltung
|
||||
*
|
||||
* QR-Scanner fuer Pairing, Moduswahl, GPS-Toggle, Log-Viewer.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Switch,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import rvs, { ConnectionState, RVSMessage, ConnectionConfig } from '../services/rvs';
|
||||
import ModeSelector from '../components/ModeSelector';
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
source: string;
|
||||
message: string;
|
||||
level: 'info' | 'warn' | 'error';
|
||||
}
|
||||
|
||||
interface EventEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
type LogTab = 'live' | 'events';
|
||||
|
||||
// Container-Farben fuer Live-Logs
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
'aria-core': '#4A9EFF', // Blau
|
||||
bridge: '#FFD60A', // Gelb
|
||||
proxy: '#FFFFFF', // Weiss
|
||||
rvs: '#34C759', // Gruen
|
||||
default: '#8888AA', // Grau
|
||||
};
|
||||
|
||||
// --- Komponente ---
|
||||
|
||||
const SettingsScreen: React.FC = () => {
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
||||
const [manualToken, setManualToken] = useState('');
|
||||
const [manualHost, setManualHost] = useState('');
|
||||
const [manualPort, setManualPort] = useState('8765');
|
||||
const [currentMode, setCurrentMode] = useState('normal');
|
||||
const [gpsEnabled, setGpsEnabled] = useState(false);
|
||||
const [logTab, setLogTab] = useState<LogTab>('live');
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [events, setEvents] = useState<EventEntry[]>([]);
|
||||
|
||||
let logIdCounter = 0;
|
||||
|
||||
// RVS-Nachrichten abonnieren (Logs und Events)
|
||||
useEffect(() => {
|
||||
const unsubState = rvs.onStateChange(setConnectionState);
|
||||
setConnectionState(rvs.getState());
|
||||
|
||||
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
|
||||
if (message.type === 'log') {
|
||||
const entry: LogEntry = {
|
||||
id: `log_${Date.now()}_${logIdCounter++}`,
|
||||
timestamp: message.timestamp,
|
||||
source: (message.payload.source as string) || 'default',
|
||||
message: (message.payload.message as string) || '',
|
||||
level: (message.payload.level as 'info' | 'warn' | 'error') || 'info',
|
||||
};
|
||||
setLogs(prev => [...prev.slice(-200), entry]); // Max 200 Eintraege behalten
|
||||
}
|
||||
|
||||
if (message.type === 'event') {
|
||||
const entry: EventEntry = {
|
||||
id: `evt_${Date.now()}_${logIdCounter++}`,
|
||||
timestamp: message.timestamp,
|
||||
title: (message.payload.title as string) || '',
|
||||
description: (message.payload.description as string) || '',
|
||||
};
|
||||
setEvents(prev => [...prev.slice(-100), entry]);
|
||||
}
|
||||
|
||||
// Modus-Bestaetigung
|
||||
if (message.type === 'mode') {
|
||||
const mode = message.payload.mode as string;
|
||||
if (mode) setCurrentMode(mode);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubState();
|
||||
unsubMessage();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- QR-Code scannen ---
|
||||
|
||||
const openQRScanner = useCallback(() => {
|
||||
// In Produktion: QR-Scanner oeffnen (react-native-camera)
|
||||
// Format: aria://host:port?token=xxx&tls=1
|
||||
Alert.alert(
|
||||
'QR-Scanner',
|
||||
'QR-Code Scanner wird in der naechsten Version implementiert.\n\nBitte Token manuell eingeben.',
|
||||
);
|
||||
}, []);
|
||||
|
||||
// --- Manuelle Verbindung ---
|
||||
|
||||
const connectManually = useCallback(() => {
|
||||
if (!manualHost.trim() || !manualToken.trim()) {
|
||||
Alert.alert('Fehler', 'Host und Token muessen angegeben werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
const config: ConnectionConfig = {
|
||||
host: manualHost.trim(),
|
||||
port: parseInt(manualPort, 10) || 8765,
|
||||
token: manualToken.trim(),
|
||||
useTLS: true,
|
||||
};
|
||||
|
||||
rvs.setConfig(config);
|
||||
rvs.connect();
|
||||
}, [manualHost, manualPort, manualToken]);
|
||||
|
||||
const disconnectRVS = useCallback(() => {
|
||||
rvs.disconnect();
|
||||
}, []);
|
||||
|
||||
// --- GPS Toggle ---
|
||||
|
||||
const handleGPSToggle = useCallback((value: boolean) => {
|
||||
setGpsEnabled(value);
|
||||
// In Produktion: Wert in AsyncStorage persistieren
|
||||
}, []);
|
||||
|
||||
// --- Modus aendern ---
|
||||
|
||||
const handleModeChange = useCallback((modeId: string) => {
|
||||
setCurrentMode(modeId);
|
||||
}, []);
|
||||
|
||||
// --- Zeitformat ---
|
||||
|
||||
const formatTime = (ts: number): string => {
|
||||
return new Date(ts).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// --- Verbindungsstatus ---
|
||||
|
||||
const connectionDotColor =
|
||||
connectionState === 'connected' ? '#34C759' :
|
||||
connectionState === 'connecting' ? '#FFD60A' : '#FF3B30';
|
||||
|
||||
const connectionLabel =
|
||||
connectionState === 'connected' ? 'Verbunden' :
|
||||
connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt';
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
|
||||
{/* === Verbindung === */}
|
||||
<Text style={styles.sectionTitle}>Verbindung</Text>
|
||||
<View style={styles.card}>
|
||||
{/* Status-Anzeige */}
|
||||
<View style={styles.statusRow}>
|
||||
<View style={[styles.statusDot, { backgroundColor: connectionDotColor }]} />
|
||||
<Text style={styles.statusLabel}>{connectionLabel}</Text>
|
||||
{connectionState === 'connected' && (
|
||||
<TouchableOpacity style={styles.disconnectButton} onPress={disconnectRVS}>
|
||||
<Text style={styles.disconnectText}>Trennen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* QR-Scanner */}
|
||||
<TouchableOpacity style={styles.qrButton} onPress={openQRScanner}>
|
||||
<Text style={styles.qrIcon}>{'\uD83D\uDCF1'}</Text>
|
||||
<Text style={styles.qrText}>QR-Code scannen (Pairing)</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Manuelle Eingabe */}
|
||||
<Text style={styles.inputLabel}>Host</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={manualHost}
|
||||
onChangeText={setManualHost}
|
||||
placeholder="z.B. aria.example.com"
|
||||
placeholderTextColor="#555570"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
<Text style={styles.inputLabel}>Port</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={manualPort}
|
||||
onChangeText={setManualPort}
|
||||
placeholder="8765"
|
||||
placeholderTextColor="#555570"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
|
||||
<Text style={styles.inputLabel}>Token</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={manualToken}
|
||||
onChangeText={setManualToken}
|
||||
placeholder="Verbindungs-Token"
|
||||
placeholderTextColor="#555570"
|
||||
autoCapitalize="none"
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<TouchableOpacity style={styles.connectButton} onPress={connectManually}>
|
||||
<Text style={styles.connectButtonText}>Verbinden</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* === Modus === */}
|
||||
<Text style={styles.sectionTitle}>Betriebsmodus</Text>
|
||||
<View style={styles.card}>
|
||||
<ModeSelector currentModeId={currentMode} onModeChange={handleModeChange} />
|
||||
</View>
|
||||
|
||||
{/* === GPS === */}
|
||||
<Text style={styles.sectionTitle}>Standort</Text>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.toggleRow}>
|
||||
<View style={styles.toggleInfo}>
|
||||
<Text style={styles.toggleLabel}>GPS-Position mitsenden</Text>
|
||||
<Text style={styles.toggleHint}>
|
||||
Standort wird automatisch an Nachrichten angehaengt
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={gpsEnabled}
|
||||
onValueChange={handleGPSToggle}
|
||||
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||
thumbColor={gpsEnabled ? '#FFFFFF' : '#666680'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* === Logs === */}
|
||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||
<View style={styles.card}>
|
||||
{/* Tab-Umschalter */}
|
||||
<View style={styles.tabRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, logTab === 'live' && styles.tabActive]}
|
||||
onPress={() => setLogTab('live')}
|
||||
>
|
||||
<Text style={[styles.tabText, logTab === 'live' && styles.tabTextActive]}>
|
||||
Live Logs
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, logTab === 'events' && styles.tabActive]}
|
||||
onPress={() => setLogTab('events')}
|
||||
>
|
||||
<Text style={[styles.tabText, logTab === 'events' && styles.tabTextActive]}>
|
||||
Events
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Log-Inhalt */}
|
||||
<View style={styles.logContainer}>
|
||||
{logTab === 'live' ? (
|
||||
logs.length > 0 ? (
|
||||
logs.slice(-50).map(log => (
|
||||
<View key={log.id} style={styles.logEntry}>
|
||||
<Text style={styles.logTime}>{formatTime(log.timestamp)}</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.logSource,
|
||||
{ color: SOURCE_COLORS[log.source] || SOURCE_COLORS.default },
|
||||
]}
|
||||
>
|
||||
[{log.source}]
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.logMessage,
|
||||
log.level === 'error' && styles.logError,
|
||||
log.level === 'warn' && styles.logWarn,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{log.message}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<Text style={styles.emptyLog}>Noch keine Logs empfangen</Text>
|
||||
)
|
||||
) : (
|
||||
events.length > 0 ? (
|
||||
events.slice(-30).map(event => (
|
||||
<View key={event.id} style={styles.eventEntry}>
|
||||
<Text style={styles.eventTime}>{formatTime(event.timestamp)}</Text>
|
||||
<Text style={styles.eventTitle}>{event.title}</Text>
|
||||
<Text style={styles.eventDescription}>{event.description}</Text>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<Text style={styles.emptyLog}>Noch keine Events empfangen</Text>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Log-Aktionen */}
|
||||
<TouchableOpacity
|
||||
style={styles.clearButton}
|
||||
onPress={() => {
|
||||
if (logTab === 'live') setLogs([]);
|
||||
else setEvents([]);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.clearButtonText}>Protokoll l\u00F6schen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* === About === */}
|
||||
<Text style={styles.sectionTitle}>{'\u00DC'}ber</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
|
||||
<Text style={styles.aboutVersion}>Version 0.1.0 (Alpha)</Text>
|
||||
<Text style={styles.aboutInfo}>
|
||||
Stefans Kommandozentrale f{'\u00FC'}r ARIA.{'\n'}
|
||||
Gebaut mit React Native + TypeScript.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Platz am Ende */}
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0D0D1A',
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: '#8888AA',
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
marginTop: 20,
|
||||
marginBottom: 8,
|
||||
marginLeft: 4,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#12122A',
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1E1E2E',
|
||||
},
|
||||
|
||||
// Verbindungsstatus
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
statusDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
marginRight: 10,
|
||||
},
|
||||
statusLabel: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
disconnectButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
backgroundColor: 'rgba(255, 59, 48, 0.2)',
|
||||
},
|
||||
disconnectText: {
|
||||
color: '#FF3B30',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// QR-Button
|
||||
qrButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
marginBottom: 16,
|
||||
},
|
||||
qrIcon: {
|
||||
fontSize: 22,
|
||||
marginRight: 10,
|
||||
},
|
||||
qrText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// Eingabefelder
|
||||
inputLabel: {
|
||||
color: '#8888AA',
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
marginLeft: 2,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
color: '#FFFFFF',
|
||||
fontSize: 15,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A2A3E',
|
||||
},
|
||||
connectButton: {
|
||||
backgroundColor: '#0096FF',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
connectButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
|
||||
// Toggle
|
||||
toggleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
toggleInfo: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
toggleLabel: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
toggleHint: {
|
||||
color: '#666680',
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// Logs
|
||||
tabRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#1E1E2E',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: '#0096FF',
|
||||
},
|
||||
tabText: {
|
||||
color: '#666680',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tabTextActive: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
logContainer: {
|
||||
maxHeight: 300,
|
||||
backgroundColor: '#0A0A18',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
},
|
||||
logEntry: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 4,
|
||||
},
|
||||
logTime: {
|
||||
color: '#555570',
|
||||
fontSize: 11,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
marginRight: 6,
|
||||
},
|
||||
logSource: {
|
||||
fontSize: 11,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
fontWeight: '700',
|
||||
marginRight: 6,
|
||||
},
|
||||
logMessage: {
|
||||
color: '#CCCCDD',
|
||||
fontSize: 11,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
flex: 1,
|
||||
},
|
||||
logError: {
|
||||
color: '#FF3B30',
|
||||
},
|
||||
logWarn: {
|
||||
color: '#FFD60A',
|
||||
},
|
||||
emptyLog: {
|
||||
color: '#555570',
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
eventEntry: {
|
||||
marginBottom: 10,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
eventTime: {
|
||||
color: '#555570',
|
||||
fontSize: 11,
|
||||
},
|
||||
eventTitle: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginTop: 2,
|
||||
},
|
||||
eventDescription: {
|
||||
color: '#8888AA',
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
},
|
||||
clearButton: {
|
||||
marginTop: 10,
|
||||
padding: 10,
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#1E1E2E',
|
||||
},
|
||||
clearButtonText: {
|
||||
color: '#666680',
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// About
|
||||
aboutTitle: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
aboutVersion: {
|
||||
color: '#0096FF',
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
},
|
||||
aboutInfo: {
|
||||
color: '#666680',
|
||||
fontSize: 13,
|
||||
marginTop: 8,
|
||||
lineHeight: 20,
|
||||
},
|
||||
|
||||
bottomSpacer: {
|
||||
height: 40,
|
||||
},
|
||||
});
|
||||
|
||||
export default SettingsScreen;
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Audio-Service fuer Sprach-Ein-/Ausgabe
|
||||
*
|
||||
* Verwaltet Mikrofon-Aufnahme und TTS-Audiowiedergabe.
|
||||
* Nutzt react-native-sound und die nativen Audio-APIs.
|
||||
*/
|
||||
|
||||
import { Platform, PermissionsAndroid } from 'react-native';
|
||||
import Sound from 'react-native-sound';
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
export interface RecordingResult {
|
||||
/** Base64-kodierte Audiodaten */
|
||||
base64: string;
|
||||
/** Dauer in Millisekunden */
|
||||
durationMs: number;
|
||||
/** MIME-Type (z.B. audio/wav) */
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export type RecordingState = 'idle' | 'recording' | 'processing';
|
||||
|
||||
type RecordingStateCallback = (state: RecordingState) => void;
|
||||
|
||||
// --- Konstanten ---
|
||||
|
||||
const AUDIO_SAMPLE_RATE = 16000;
|
||||
const AUDIO_CHANNELS = 1;
|
||||
const AUDIO_ENCODING = 'audio/wav';
|
||||
|
||||
// --- Audio-Service ---
|
||||
|
||||
class AudioService {
|
||||
private recordingState: RecordingState = 'idle';
|
||||
private recordingStartTime: number = 0;
|
||||
private stateListeners: RecordingStateCallback[] = [];
|
||||
private currentSound: Sound | null = null;
|
||||
|
||||
// --- Berechtigungen ---
|
||||
|
||||
/** Mikrofon-Berechtigung anfordern */
|
||||
async requestMicrophonePermission(): Promise<boolean> {
|
||||
if (Platform.OS !== 'android') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const granted = await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
|
||||
{
|
||||
title: 'ARIA Cockpit - Mikrofon',
|
||||
message: 'ARIA benoetigt Zugriff auf das Mikrofon fuer Spracheingabe.',
|
||||
buttonPositive: 'Erlauben',
|
||||
buttonNegative: 'Ablehnen',
|
||||
},
|
||||
);
|
||||
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||
} catch (err) {
|
||||
console.error('[Audio] Fehler bei Berechtigungsanfrage:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Aufnahme ---
|
||||
|
||||
/** Mikrofon-Aufnahme starten */
|
||||
async startRecording(): Promise<boolean> {
|
||||
if (this.recordingState !== 'idle') {
|
||||
console.warn('[Audio] Aufnahme laeuft bereits');
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasPermission = await this.requestMicrophonePermission();
|
||||
if (!hasPermission) {
|
||||
console.warn('[Audio] Keine Mikrofon-Berechtigung');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Nativer Aufnahme-Start ueber AudioRecorder-Bridge
|
||||
// In Produktion: Native Module oder react-native-audio-recorder-player nutzen
|
||||
this.recordingStartTime = Date.now();
|
||||
this.setState('recording');
|
||||
console.log('[Audio] Aufnahme gestartet');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Audio] Fehler beim Starten der Aufnahme:', err);
|
||||
this.setState('idle');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Aufnahme stoppen und Ergebnis zurueckgeben */
|
||||
async stopRecording(): Promise<RecordingResult | null> {
|
||||
if (this.recordingState !== 'recording') {
|
||||
console.warn('[Audio] Keine aktive Aufnahme');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.setState('processing');
|
||||
|
||||
try {
|
||||
const durationMs = Date.now() - this.recordingStartTime;
|
||||
|
||||
// In Produktion: Audiodaten vom nativen Recorder holen
|
||||
// const audioData = await NativeAudioRecorder.stop();
|
||||
const base64Placeholder = ''; // Platzhalter bis Native-Bridge implementiert
|
||||
|
||||
this.setState('idle');
|
||||
|
||||
console.log(`[Audio] Aufnahme beendet (${durationMs}ms)`);
|
||||
|
||||
return {
|
||||
base64: base64Placeholder,
|
||||
durationMs,
|
||||
mimeType: AUDIO_ENCODING,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[Audio] Fehler beim Stoppen der Aufnahme:', err);
|
||||
this.setState('idle');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wiedergabe ---
|
||||
|
||||
/** Base64-kodiertes Audio abspielen (z.B. TTS-Antwort von ARIA) */
|
||||
async playAudio(base64Data: string): Promise<void> {
|
||||
// Laufende Wiedergabe stoppen
|
||||
this.stopPlayback();
|
||||
|
||||
try {
|
||||
// Base64-Daten in temporaere Datei schreiben und abspielen
|
||||
// In Produktion: react-native-fs + Sound kombinieren
|
||||
const tmpPath = `${Platform.OS === 'android' ? '/data/user/0/' : ''}aria_tts_temp.wav`;
|
||||
|
||||
// Platzhalter: Sound aus Datei laden
|
||||
this.currentSound = new Sound(tmpPath, '', (error) => {
|
||||
if (error) {
|
||||
console.error('[Audio] Fehler beim Laden:', error);
|
||||
return;
|
||||
}
|
||||
this.currentSound?.play((success) => {
|
||||
if (success) {
|
||||
console.log('[Audio] Wiedergabe abgeschlossen');
|
||||
} else {
|
||||
console.warn('[Audio] Wiedergabe fehlgeschlagen');
|
||||
}
|
||||
this.currentSound?.release();
|
||||
this.currentSound = null;
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Audio] Wiedergabefehler:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Laufende Wiedergabe stoppen */
|
||||
stopPlayback(): void {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.stop();
|
||||
this.currentSound.release();
|
||||
this.currentSound = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Status ---
|
||||
|
||||
getRecordingState(): RecordingState {
|
||||
return this.recordingState;
|
||||
}
|
||||
|
||||
/** Callback fuer Aufnahmestatus-Aenderungen */
|
||||
onStateChange(callback: RecordingStateCallback): () => void {
|
||||
this.stateListeners.push(callback);
|
||||
return () => {
|
||||
this.stateListeners = this.stateListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
private setState(state: RecordingState): void {
|
||||
if (this.recordingState !== state) {
|
||||
this.recordingState = state;
|
||||
this.stateListeners.forEach(cb => cb(state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
const audioService = new AudioService();
|
||||
export default audioService;
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* RVS (Rendezvous Server) - WebSocket-Verbindungsmanager
|
||||
*
|
||||
* Verwaltet die persistente WebSocket-Verbindung zwischen der ARIA Cockpit App
|
||||
* und dem Rendezvous Server. Unterstützt Auto-Reconnect, Heartbeat und
|
||||
* typisierte Nachrichten.
|
||||
*/
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
export type MessageType = 'chat' | 'audio' | 'file' | 'location' | 'mode' | 'log' | 'event';
|
||||
|
||||
export interface RVSMessage {
|
||||
type: MessageType;
|
||||
payload: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
useTLS: boolean;
|
||||
}
|
||||
|
||||
type MessageCallback = (message: RVSMessage) => void;
|
||||
type StateCallback = (state: ConnectionState) => void;
|
||||
|
||||
// --- Konstanten ---
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 25_000;
|
||||
const INITIAL_RECONNECT_DELAY_MS = 1_000;
|
||||
const MAX_RECONNECT_DELAY_MS = 30_000;
|
||||
const RECONNECT_BACKOFF_FACTOR = 2;
|
||||
|
||||
// --- RVS-Klasse ---
|
||||
|
||||
class RVSConnection {
|
||||
private ws: WebSocket | null = null;
|
||||
private config: ConnectionConfig | null = null;
|
||||
private state: ConnectionState = 'disconnected';
|
||||
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectDelay: number = INITIAL_RECONNECT_DELAY_MS;
|
||||
private shouldReconnect: boolean = false;
|
||||
|
||||
private messageListeners: MessageCallback[] = [];
|
||||
private stateListeners: StateCallback[] = [];
|
||||
|
||||
// --- Konfiguration ---
|
||||
|
||||
/** Verbindungsdaten setzen (z.B. nach QR-Scan) */
|
||||
setConfig(config: ConnectionConfig): void {
|
||||
this.config = config;
|
||||
this.saveConfig(config);
|
||||
}
|
||||
|
||||
getConfig(): ConnectionConfig | null {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
getState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// --- Verbindung ---
|
||||
|
||||
/** Verbindung zum RVS aufbauen */
|
||||
connect(): void {
|
||||
if (!this.config) {
|
||||
console.warn('[RVS] Keine Verbindungskonfiguration vorhanden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
console.log('[RVS] Bereits verbunden');
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldReconnect = true;
|
||||
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
||||
this.establishConnection();
|
||||
}
|
||||
|
||||
/** Verbindung trennen (kein Auto-Reconnect) */
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
this.clearTimers();
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Benutzer hat getrennt');
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.setState('disconnected');
|
||||
}
|
||||
|
||||
/** Nachricht an den RVS senden */
|
||||
send(type: MessageType, payload: Record<string, unknown>): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('[RVS] Kann nicht senden - nicht verbunden');
|
||||
return;
|
||||
}
|
||||
|
||||
const message: RVSMessage = {
|
||||
type,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
// --- Event-Listener ---
|
||||
|
||||
/** Callback fuer eingehende Nachrichten registrieren */
|
||||
onMessage(callback: MessageCallback): () => void {
|
||||
this.messageListeners.push(callback);
|
||||
// Gibt Unsubscribe-Funktion zurueck
|
||||
return () => {
|
||||
this.messageListeners = this.messageListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
/** Callback fuer Verbindungsstatus-Aenderungen registrieren */
|
||||
onStateChange(callback: StateCallback): () => void {
|
||||
this.stateListeners.push(callback);
|
||||
return () => {
|
||||
this.stateListeners = this.stateListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
// --- Interne Methoden ---
|
||||
|
||||
private establishConnection(): void {
|
||||
if (!this.config) return;
|
||||
|
||||
this.setState('connecting');
|
||||
|
||||
const protocol = this.config.useTLS ? 'wss' : 'ws';
|
||||
const url = `${protocol}://${this.config.host}:${this.config.port}?token=${this.config.token}`;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('[RVS] Verbunden');
|
||||
this.setState('connected');
|
||||
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
||||
this.startHeartbeat();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event: WebSocketMessageEvent) => {
|
||||
try {
|
||||
const message: RVSMessage = JSON.parse(event.data as string);
|
||||
this.notifyMessageListeners(message);
|
||||
} catch (err) {
|
||||
console.error('[RVS] Fehler beim Parsen der Nachricht:', err);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log(`[RVS] Verbindung geschlossen (Code: ${event.code})`);
|
||||
this.clearTimers();
|
||||
this.ws = null;
|
||||
this.setState('disconnected');
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('[RVS] WebSocket-Fehler:', error);
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[RVS] Verbindungsfehler:', err);
|
||||
this.setState('disconnected');
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconnect mit exponentiellem Backoff planen */
|
||||
private scheduleReconnect(): void {
|
||||
console.log(`[RVS] Reconnect in ${this.reconnectDelay / 1000}s...`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.establishConnection();
|
||||
}, this.reconnectDelay);
|
||||
|
||||
// Exponentieller Backoff: 1s -> 2s -> 4s -> 8s -> ... -> max 30s
|
||||
this.reconnectDelay = Math.min(
|
||||
this.reconnectDelay * RECONNECT_BACKOFF_FACTOR,
|
||||
MAX_RECONNECT_DELAY_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Heartbeat starten (alle 25 Sekunden) */
|
||||
private startHeartbeat(): void {
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() }));
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState): void {
|
||||
if (this.state !== state) {
|
||||
this.state = state;
|
||||
this.stateListeners.forEach(cb => cb(state));
|
||||
}
|
||||
}
|
||||
|
||||
private notifyMessageListeners(message: RVSMessage): void {
|
||||
this.messageListeners.forEach(cb => cb(message));
|
||||
}
|
||||
|
||||
// --- Persistenz (AsyncStorage Wrapper) ---
|
||||
|
||||
private async saveConfig(config: ConnectionConfig): Promise<void> {
|
||||
try {
|
||||
// In Produktion: AsyncStorage verwenden
|
||||
// await AsyncStorage.setItem('rvs_config', JSON.stringify(config));
|
||||
console.log('[RVS] Konfiguration gespeichert');
|
||||
} catch (err) {
|
||||
console.error('[RVS] Fehler beim Speichern:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async loadConfig(): Promise<ConnectionConfig | null> {
|
||||
try {
|
||||
// In Produktion: AsyncStorage verwenden
|
||||
// const data = await AsyncStorage.getItem('rvs_config');
|
||||
// if (data) { this.config = JSON.parse(data); return this.config; }
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('[RVS] Fehler beim Laden:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz
|
||||
const rvs = new RVSConnection();
|
||||
export default rvs;
|
||||
Reference in New Issue
Block a user