1055 lines
32 KiB
TypeScript
1055 lines
32 KiB
TypeScript
/**
|
|
* SettingsScreen - Einstellungen und Verbindungsverwaltung
|
|
*
|
|
* QR-Scanner fuer Pairing, Moduswahl, GPS-Toggle, Log-Viewer.
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
ScrollView,
|
|
Switch,
|
|
StyleSheet,
|
|
Alert,
|
|
Platform,
|
|
} from 'react-native';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import RNFS from 'react-native-fs';
|
|
import DocumentPicker from 'react-native-document-picker';
|
|
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
|
|
import ModeSelector from '../components/ModeSelector';
|
|
import QRScanner from '../components/QRScanner';
|
|
|
|
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
|
const DEFAULT_STORAGE_PATH = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
|
|
|
// --- 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 [scannerVisible, setScannerVisible] = useState(false);
|
|
const [logTab, setLogTab] = useState<LogTab>('live');
|
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
const [events, setEvents] = useState<EventEntry[]>([]);
|
|
const [connLog, setConnLog] = useState<ConnectionLogEntry[]>(rvs.getConnectionLog());
|
|
const [storagePath, setStoragePath] = useState(DEFAULT_STORAGE_PATH);
|
|
const [autoDownload, setAutoDownload] = useState(true);
|
|
const [storageSize, setStorageSize] = useState('...');
|
|
const [ttsEnabled, setTtsEnabled] = useState(true);
|
|
const [defaultVoice, setDefaultVoice] = useState('ramona');
|
|
const [highlightVoice, setHighlightVoice] = useState('thorsten');
|
|
const [speechSpeed, setSpeechSpeed] = useState(1.0);
|
|
const [editingPath, setEditingPath] = useState(false);
|
|
const [tempPath, setTempPath] = useState('');
|
|
|
|
let logIdCounter = 0;
|
|
|
|
// Gespeicherte Config in die Felder laden
|
|
useEffect(() => {
|
|
const config = rvs.getConfig();
|
|
if (config) {
|
|
setManualHost(config.host);
|
|
setManualPort(String(config.port));
|
|
setManualToken(config.token);
|
|
}
|
|
// Speicherpfad + Auto-Download laden
|
|
AsyncStorage.getItem(STORAGE_PATH_KEY).then(saved => {
|
|
if (saved) setStoragePath(saved);
|
|
});
|
|
AsyncStorage.getItem('aria_auto_download').then(saved => {
|
|
if (saved !== null) setAutoDownload(saved === 'true');
|
|
});
|
|
AsyncStorage.getItem('aria_tts_enabled').then(saved => {
|
|
if (saved !== null) setTtsEnabled(saved === 'true');
|
|
});
|
|
AsyncStorage.getItem('aria_default_voice').then(saved => {
|
|
if (saved) setDefaultVoice(saved);
|
|
});
|
|
AsyncStorage.getItem('aria_highlight_voice').then(saved => {
|
|
if (saved) setHighlightVoice(saved);
|
|
});
|
|
AsyncStorage.getItem('aria_speech_speed').then(saved => {
|
|
if (saved) setSpeechSpeed(parseFloat(saved));
|
|
});
|
|
}, []);
|
|
|
|
// Speichergroesse berechnen
|
|
useEffect(() => {
|
|
const calcSize = async () => {
|
|
try {
|
|
const exists = await RNFS.exists(storagePath);
|
|
if (!exists) { setStorageSize('0 KB'); return; }
|
|
const items = await RNFS.readDir(storagePath);
|
|
const totalBytes = items.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
if (totalBytes > 1024 * 1024) {
|
|
setStorageSize(`${(totalBytes / 1024 / 1024).toFixed(1)} MB (${items.length} Dateien)`);
|
|
} else {
|
|
setStorageSize(`${Math.round(totalBytes / 1024)} KB (${items.length} Dateien)`);
|
|
}
|
|
} catch { setStorageSize('nicht verfuegbar'); }
|
|
};
|
|
calcSize();
|
|
}, [storagePath]);
|
|
|
|
const saveStoragePath = useCallback(async (newPath: string) => {
|
|
const clean = newPath.trim();
|
|
if (!clean) return;
|
|
await AsyncStorage.setItem(STORAGE_PATH_KEY, clean);
|
|
setStoragePath(clean);
|
|
setEditingPath(false);
|
|
Alert.alert('Gespeichert', `Neuer Speicherort:\n${clean}\n\nWird ab der naechsten Nachricht verwendet.`);
|
|
}, []);
|
|
|
|
const showPathPicker = useCallback(() => {
|
|
Alert.alert(
|
|
'Speicherort waehlen',
|
|
'Wo sollen Anhaenge gespeichert werden?',
|
|
[
|
|
{
|
|
text: 'Ordner auswaehlen...',
|
|
onPress: async () => {
|
|
try {
|
|
const result = await DocumentPicker.pickDirectory();
|
|
if (result?.uri) {
|
|
// SAF URI decodieren (content://com.android.externalstorage...)
|
|
const decoded = decodeURIComponent(result.uri);
|
|
// Versuche einen lesbaren Pfad zu extrahieren
|
|
const match = decoded.match(/primary[:%]3A(.+)/);
|
|
const readablePath = match
|
|
? `/storage/emulated/0/${match[1].replace(/%2F|%3A/g, '/')}`
|
|
: decoded;
|
|
saveStoragePath(readablePath);
|
|
}
|
|
} catch (e: any) {
|
|
if (!DocumentPicker.isCancel(e)) {
|
|
Alert.alert('Fehler', 'Ordnerauswahl fehlgeschlagen');
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
text: 'App-intern (Standard)',
|
|
onPress: () => saveStoragePath(DEFAULT_STORAGE_PATH),
|
|
},
|
|
{
|
|
text: 'Pfad manuell eingeben',
|
|
onPress: () => { setTempPath(storagePath); setEditingPath(true); },
|
|
},
|
|
{ text: 'Abbrechen', style: 'cancel' as const },
|
|
],
|
|
);
|
|
}, [storagePath]);
|
|
|
|
const clearStorageCache = useCallback(async () => {
|
|
Alert.alert(
|
|
'Cache loeschen',
|
|
`Alle lokalen Anhaenge in\n${storagePath}\nloeschen?\n\nDateien koennen ueber RVS erneut heruntergeladen werden.`,
|
|
[
|
|
{ text: 'Abbrechen', style: 'cancel' },
|
|
{
|
|
text: 'Loeschen',
|
|
style: 'destructive',
|
|
onPress: async () => {
|
|
try {
|
|
const exists = await RNFS.exists(storagePath);
|
|
if (exists) await RNFS.unlink(storagePath);
|
|
await RNFS.mkdir(storagePath);
|
|
setStorageSize('0 KB (0 Dateien)');
|
|
Alert.alert('Erledigt', 'Cache geleert. Anhaenge werden bei Bedarf neu geladen.');
|
|
} catch (e: any) {
|
|
Alert.alert('Fehler', e.message);
|
|
}
|
|
},
|
|
},
|
|
],
|
|
);
|
|
}, [storagePath]);
|
|
|
|
// RVS-Nachrichten und Verbindungslog abonnieren
|
|
useEffect(() => {
|
|
const unsubState = rvs.onStateChange(setConnectionState);
|
|
setConnectionState(rvs.getState());
|
|
|
|
const unsubLog = rvs.onLog((entry) => {
|
|
setConnLog(prev => [...prev.slice(-99), entry]);
|
|
});
|
|
|
|
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();
|
|
unsubLog();
|
|
};
|
|
}, []);
|
|
|
|
// --- QR-Code scannen ---
|
|
|
|
const openQRScanner = useCallback(() => {
|
|
setScannerVisible(true);
|
|
}, []);
|
|
|
|
const handleQRScan = useCallback((config: ConnectionConfig) => {
|
|
setScannerVisible(false);
|
|
|
|
// Felder im UI aktualisieren
|
|
setManualHost(config.host);
|
|
setManualPort(String(config.port));
|
|
setManualToken(config.token);
|
|
|
|
// Direkt verbinden
|
|
rvs.setConfig(config);
|
|
rvs.connect();
|
|
|
|
Alert.alert(
|
|
'Pairing erfolgreich',
|
|
`Verbinde mit ${config.host}:${config.port}...`,
|
|
);
|
|
}, []);
|
|
|
|
// --- 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 (
|
|
<>
|
|
<QRScanner
|
|
visible={scannerVisible}
|
|
onScan={handleQRScan}
|
|
onClose={() => setScannerVisible(false)}
|
|
/>
|
|
<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>
|
|
|
|
{/* === Verbindungslog === */}
|
|
<Text style={styles.sectionTitle}>Verbindungslog</Text>
|
|
<View style={styles.card}>
|
|
<ScrollView
|
|
style={styles.connLogScroll}
|
|
nestedScrollEnabled={true}
|
|
ref={(ref) => {
|
|
// Auto-Scroll nach unten bei neuen Eintraegen
|
|
if (ref && connLog.length > 0) {
|
|
setTimeout(() => ref.scrollToEnd({ animated: false }), 50);
|
|
}
|
|
}}
|
|
>
|
|
{connLog.length > 0 ? (
|
|
connLog.slice(-50).map((entry, idx) => (
|
|
<View key={`cl_${idx}`} style={styles.logEntry}>
|
|
<Text style={styles.logTime}>{formatTime(entry.timestamp)}</Text>
|
|
<Text
|
|
style={[
|
|
styles.logMessage,
|
|
entry.level === 'error' && styles.logError,
|
|
entry.level === 'warn' && styles.logWarn,
|
|
]}
|
|
numberOfLines={3}
|
|
>
|
|
{entry.message}
|
|
</Text>
|
|
</View>
|
|
))
|
|
) : (
|
|
<Text style={styles.emptyLog}>Noch keine Verbindungsversuche</Text>
|
|
)}
|
|
</ScrollView>
|
|
<TouchableOpacity
|
|
style={styles.clearButton}
|
|
onPress={() => setConnLog([])}
|
|
>
|
|
<Text style={styles.clearButtonText}>Log l{'\u00F6'}schen</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>
|
|
|
|
{/* === Sprachausgabe === */}
|
|
<Text style={styles.sectionTitle}>Sprachausgabe</Text>
|
|
<View style={styles.card}>
|
|
{/* TTS An/Aus */}
|
|
<View style={styles.toggleRow}>
|
|
<View style={styles.toggleInfo}>
|
|
<Text style={styles.toggleLabel}>Sprachausgabe</Text>
|
|
<Text style={styles.toggleHint}>ARIA antwortet per Sprache (TTS)</Text>
|
|
</View>
|
|
<Switch
|
|
value={ttsEnabled}
|
|
onValueChange={(val) => {
|
|
setTtsEnabled(val);
|
|
AsyncStorage.setItem('aria_tts_enabled', String(val));
|
|
rvs.send('config' as any, { ttsEnabled: val });
|
|
}}
|
|
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
|
thumbColor={ttsEnabled ? '#FFFFFF' : '#666680'}
|
|
/>
|
|
</View>
|
|
|
|
{/* Standard-Stimme */}
|
|
<View style={{marginTop: 16}}>
|
|
<Text style={styles.toggleLabel}>Standard-Stimme</Text>
|
|
<Text style={styles.toggleHint}>Fuer normale Antworten und Gespraeche</Text>
|
|
<View style={{flexDirection: 'row', gap: 8, marginTop: 8}}>
|
|
<TouchableOpacity
|
|
style={[styles.voiceBtn, defaultVoice === 'ramona' && styles.voiceBtnActive]}
|
|
onPress={() => { setDefaultVoice('ramona'); AsyncStorage.setItem('aria_default_voice', 'ramona'); rvs.send('config' as any, { defaultVoice: 'ramona' }); }}
|
|
>
|
|
<Text style={styles.voiceBtnIcon}>{'\uD83D\uDE4E\u200D\u2640\uFE0F'}</Text>
|
|
<Text style={[styles.voiceBtnText, defaultVoice === 'ramona' && styles.voiceBtnTextActive]}>Ramona</Text>
|
|
<Text style={styles.voiceBtnHint}>Weiblich, warm</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.voiceBtn, defaultVoice === 'thorsten' && styles.voiceBtnActive]}
|
|
onPress={() => { setDefaultVoice('thorsten'); AsyncStorage.setItem('aria_default_voice', 'thorsten'); rvs.send('config' as any, { defaultVoice: 'thorsten' }); }}
|
|
>
|
|
<Text style={styles.voiceBtnIcon}>{'\uD83E\uDDD4'}</Text>
|
|
<Text style={[styles.voiceBtnText, defaultVoice === 'thorsten' && styles.voiceBtnTextActive]}>Thorsten</Text>
|
|
<Text style={styles.voiceBtnHint}>Maennlich, tief</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Highlight-Stimme */}
|
|
<View style={{marginTop: 16}}>
|
|
<Text style={styles.toggleLabel}>Highlight-Stimme</Text>
|
|
<Text style={styles.toggleHint}>Fuer besondere Ereignisse (Deploy, Alarm, Erfolg)</Text>
|
|
<View style={{flexDirection: 'row', gap: 8, marginTop: 8}}>
|
|
<TouchableOpacity
|
|
style={[styles.voiceBtn, highlightVoice === 'thorsten' && styles.voiceBtnActive]}
|
|
onPress={() => { setHighlightVoice('thorsten'); AsyncStorage.setItem('aria_highlight_voice', 'thorsten'); rvs.send('config' as any, { highlightVoice: 'thorsten' }); }}
|
|
>
|
|
<Text style={styles.voiceBtnIcon}>{'\uD83E\uDDD4'}</Text>
|
|
<Text style={[styles.voiceBtnText, highlightVoice === 'thorsten' && styles.voiceBtnTextActive]}>Thorsten</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.voiceBtn, highlightVoice === 'ramona' && styles.voiceBtnActive]}
|
|
onPress={() => { setHighlightVoice('ramona'); AsyncStorage.setItem('aria_highlight_voice', 'ramona'); rvs.send('config' as any, { highlightVoice: 'ramona' }); }}
|
|
>
|
|
<Text style={styles.voiceBtnIcon}>{'\uD83D\uDE4E\u200D\u2640\uFE0F'}</Text>
|
|
<Text style={[styles.voiceBtnText, highlightVoice === 'ramona' && styles.voiceBtnTextActive]}>Ramona</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Sprechgeschwindigkeit */}
|
|
<View style={{marginTop: 16}}>
|
|
<Text style={styles.toggleLabel}>Sprechgeschwindigkeit: {speechSpeed.toFixed(1)}x</Text>
|
|
<View style={{flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 8}}>
|
|
<Text style={{color: '#555570', fontSize: 11}}>0.5x</Text>
|
|
<View style={{flex: 1}}>
|
|
<TouchableOpacity
|
|
style={{height: 30, justifyContent: 'center'}}
|
|
onPress={(e) => {
|
|
const layout = e.nativeEvent;
|
|
// Einfacher Tap-basierter Slider
|
|
}}
|
|
>
|
|
<View style={{height: 4, backgroundColor: '#2A2A3E', borderRadius: 2}}>
|
|
<View style={{height: 4, backgroundColor: '#0096FF', borderRadius: 2, width: `${((speechSpeed - 0.5) / 1.5) * 100}%`}} />
|
|
</View>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<Text style={{color: '#555570', fontSize: 11}}>2.0x</Text>
|
|
</View>
|
|
<View style={{flexDirection: 'row', justifyContent: 'space-around', marginTop: 8}}>
|
|
{[0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map(speed => (
|
|
<TouchableOpacity
|
|
key={speed}
|
|
onPress={() => {
|
|
setSpeechSpeed(speed);
|
|
AsyncStorage.setItem('aria_speech_speed', String(speed));
|
|
rvs.send('config' as any, { speechSpeed: speed });
|
|
}}
|
|
style={{
|
|
paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6,
|
|
backgroundColor: speechSpeed === speed ? '#0096FF' : '#1E1E2E',
|
|
}}
|
|
>
|
|
<Text style={{color: speechSpeed === speed ? '#fff' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
|
|
{speed}x
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Highlight-Trigger Info */}
|
|
<View style={{marginTop: 16, padding: 10, backgroundColor: '#1E1E2E', borderRadius: 8}}>
|
|
<Text style={styles.toggleLabel}>{'\u26A1'} Highlight-Trigger</Text>
|
|
<Text style={[styles.toggleHint, {marginTop: 4}]}>
|
|
Die Highlight-Stimme wird automatisch bei diesen Woertern verwendet:{'\n'}
|
|
deploy, erfolgreich, alarm, so soll es sein, kritisch, server down, sicherheitswarnung, ticket geloest, aufgabe abgeschlossen
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* === Speicher === */}
|
|
<Text style={styles.sectionTitle}>Anhang-Speicher</Text>
|
|
<View style={styles.card}>
|
|
<View style={styles.toggleRow}>
|
|
<View style={styles.toggleInfo}>
|
|
<Text style={styles.toggleLabel}>Auto-Download</Text>
|
|
<Text style={styles.toggleHint}>
|
|
Fehlende Anhaenge beim App-Start automatisch vom Server laden
|
|
</Text>
|
|
</View>
|
|
<Switch
|
|
value={autoDownload}
|
|
onValueChange={(val) => {
|
|
setAutoDownload(val);
|
|
AsyncStorage.setItem('aria_auto_download', String(val));
|
|
}}
|
|
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
|
thumbColor={autoDownload ? '#FFFFFF' : '#666680'}
|
|
/>
|
|
</View>
|
|
|
|
<View style={{height: 16}} />
|
|
<Text style={styles.toggleLabel}>Lokaler Speicherort</Text>
|
|
<Text style={styles.toggleHint}>
|
|
Hier werden Bilder und Dateien aus dem Chat gespeichert.
|
|
{autoDownload ? ' Fehlende Dateien werden automatisch nachgeladen.' : ' Fehlende Dateien koennen per Tippen geladen werden.'}
|
|
</Text>
|
|
|
|
{editingPath ? (
|
|
<View style={{marginTop: 10}}>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={tempPath}
|
|
onChangeText={setTempPath}
|
|
placeholder="z.B. /storage/emulated/0/ARIA/attachments"
|
|
placeholderTextColor="#555570"
|
|
autoCapitalize="none"
|
|
/>
|
|
<View style={{flexDirection: 'row', gap: 8}}>
|
|
<TouchableOpacity
|
|
style={[styles.connectButton, {flex: 1}]}
|
|
onPress={() => saveStoragePath(tempPath)}
|
|
>
|
|
<Text style={styles.connectButtonText}>Speichern</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.clearButton, {flex: 1, marginTop: 0}]}
|
|
onPress={() => setEditingPath(false)}
|
|
>
|
|
<Text style={styles.clearButtonText}>Abbrechen</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
) : (
|
|
<View style={{marginTop: 10}}>
|
|
<Text style={styles.storagePathText} numberOfLines={2}>{storagePath}</Text>
|
|
<Text style={styles.storageSizeText}>{storageSize}</Text>
|
|
<View style={{flexDirection: 'row', gap: 8, marginTop: 8}}>
|
|
<TouchableOpacity
|
|
style={[styles.clearButton, {flex: 1, marginTop: 0}]}
|
|
onPress={showPathPicker}
|
|
>
|
|
<Text style={styles.clearButtonText}>Pfad aendern</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.clearButton, {flex: 1, marginTop: 0, backgroundColor: 'rgba(255,59,48,0.15)'}]}
|
|
onPress={clearStorageCache}
|
|
>
|
|
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>Cache leeren</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</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.0.2.1 </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,
|
|
},
|
|
|
|
// Stimmen
|
|
voiceBtn: {
|
|
flex: 1,
|
|
padding: 12,
|
|
borderRadius: 10,
|
|
backgroundColor: '#1E1E2E',
|
|
alignItems: 'center',
|
|
borderWidth: 2,
|
|
borderColor: 'transparent',
|
|
},
|
|
voiceBtnActive: {
|
|
borderColor: '#0096FF',
|
|
backgroundColor: '#0D1A2E',
|
|
},
|
|
voiceBtnIcon: {
|
|
fontSize: 28,
|
|
marginBottom: 4,
|
|
},
|
|
voiceBtnText: {
|
|
color: '#8888AA',
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
},
|
|
voiceBtnTextActive: {
|
|
color: '#FFFFFF',
|
|
},
|
|
voiceBtnHint: {
|
|
color: '#555570',
|
|
fontSize: 11,
|
|
marginTop: 2,
|
|
},
|
|
|
|
// Speicher
|
|
storagePathText: {
|
|
color: '#0096FF',
|
|
fontSize: 12,
|
|
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
|
},
|
|
storageSizeText: {
|
|
color: '#8888AA',
|
|
fontSize: 12,
|
|
marginTop: 4,
|
|
},
|
|
|
|
// 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',
|
|
},
|
|
connLogScroll: {
|
|
maxHeight: 200,
|
|
backgroundColor: '#0A0A18',
|
|
borderRadius: 8,
|
|
padding: 10,
|
|
},
|
|
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;
|