feat(app): Datei-Manager, Skill-Created-Bubble, Zoom rewriten, Repair-Cleanup
Drei groessere Aenderungen in der Android-App.
Datei-Manager (Settings → Dateien)
- Neuer Eintrag im Settings-Menue → Modal mit Liste
- Suche + Filter (Alle / Von ARIA / Vom User)
- Per Eintrag: ARIA/USER-Badge, Groesse, Datum, Loeschen-Button
- file_list_request via RVS → Bridge → Diagnostic-HTTP → response
- file_delete_request loescht serverseitig, file_deleted-Event
aktualisiert ALLE Chat-Bubbles (Attachment.deleted = true mit
Strikethrough-Name + 🗑️-Icon)
Skill-Created-Bubble
- Neuer ChatMessage.skillCreated Typ — eigenes Render mit gelbem
Border, Skill-Name, Beschreibung, Execution-Mode, Active-Status
- Falls Skill-Setup fehlschlug: ⚠ Setup-Fehler-Zeile direkt in der Bubble
- Stefan sieht in der Chat-History immer wenn ARIA selbst einen
Skill angelegt hat — Transparenz statt schweigend im Hintergrund
Pinch-Zoom rewriten (ZoomableImage.tsx)
- Multi-Touch-Race-Bugs in der alten Variante geloest:
* Touch-Count jetzt aus e.nativeEvent.touches.length statt
gestureState.numberActiveTouches (war nicht zuverlaessig)
* Re-Snapshot bei JEDEM Finger-Wechsel (1↔2) → keine Spruenge mehr
* Doppel-Tap via onPanResponderRelease + Bewegungs-Cap
* pointerEvents="none" auf Image-Wrapper → Touches gehen garantiert
an PanResponder-View
* collapsable={false} verhindert Android-View-Flattening
- 2-Finger-Pinch 1x..5x, simultaner Pan via Focal,
1-Finger-Pan nur wenn gezoomt (>1.02x), Doppel-Tap toggelt 1x↔2.5x
App SettingsScreen Repair-Section
- aria-core-spezifische Buttons raus: 🔧 Reparieren, 🚨 ARIA hart neu,
🧹 Konversation komprimieren (OpenClaw ist abgerissen)
- Stattdessen generischer container_restart fuer aria-bridge/brain/qdrant
- Repair-Buttons aus der "ARIA denkt..."-Bubble entfernt (nur Abbrechen)
ChatScreen
- skill_created und file_deleted Handler im RVS-Message-Switch
- file_list_response (Modal-State liegt in SettingsScreen)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* ZoomableImage — Pinch-to-Zoom + Pan fuer das Vollbild-Modal.
|
* ZoomableImage — Pinch-to-Zoom + Pan fuers Vollbild-Modal.
|
||||||
*
|
*
|
||||||
* Reine React-Native-Implementation ohne externe Lib:
|
* Reine RN-Implementation, ohne react-native-gesture-handler.
|
||||||
* - 1 Finger: Pan wenn schon gezoomt
|
|
||||||
* - 2 Finger: Pinch fuer Zoom + Pan
|
|
||||||
* - Doppel-Tap: Toggle 1x ↔ 2.5x Zoom
|
|
||||||
*
|
*
|
||||||
* Scale wird auf [1, 5] gecapped, Translation auf das verfuegbare
|
* - 2 Finger: Pinch (Zoom 1x..5x) + simultaner Pan via Focal-Punkt
|
||||||
* Image-Volumen (kein Out-of-bounds-Pan).
|
* - 1 Finger: Pan wenn schon gezoomt (>1.02x)
|
||||||
|
* - Doppel-Tap (<300ms zw. zwei Single-Taps): Toggle 1x ↔ 2.5x
|
||||||
|
*
|
||||||
|
* Implementierungs-Hinweise zur alten Version (warum's nicht ging):
|
||||||
|
* - `gestureState.numberActiveTouches` ist nicht zuverlaessig direkt
|
||||||
|
* nach onPanResponderGrant. Wir lesen Finger-Anzahl jetzt
|
||||||
|
* ausschliesslich aus `e.nativeEvent.touches.length`.
|
||||||
|
* - Beim Wechsel von 2 → 1 Fingern bleib die Pinch-Referenz haengen.
|
||||||
|
* Jetzt: bei jedem Finger-Wechsel re-snapshotten wir die Geste.
|
||||||
|
* - Animated.Image bekommt jetzt pointerEvents="none" damit der View
|
||||||
|
* GARANTIERT die Touches abbekommt.
|
||||||
|
* - useNativeDriver ist bewusst AUS — sonst koennen wir setValue()
|
||||||
|
* nicht synchron mit dem Pan-Responder zusammen nutzen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
import React, { useMemo, useRef } from 'react';
|
||||||
import { Animated, PanResponder, View, StyleSheet, ImageStyle, StyleProp } from 'react-native';
|
import {
|
||||||
|
Animated,
|
||||||
|
PanResponder,
|
||||||
|
GestureResponderEvent,
|
||||||
|
ImageStyle,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
uri: string;
|
uri: string;
|
||||||
@@ -20,121 +37,186 @@ interface Props {
|
|||||||
style?: StyleProp<ImageStyle>;
|
style?: StyleProp<ImageStyle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIN_SCALE = 1;
|
||||||
|
const MAX_SCALE = 5;
|
||||||
|
const DOUBLE_TAP_MS = 300;
|
||||||
|
const DOUBLE_TAP_DIST = 30; // Bewegung max. damit ein Tap als Tap gilt
|
||||||
|
const PAN_SLOP_AT_SCALE_1 = 4; // Mikro-Movement nicht als Pan werten
|
||||||
|
|
||||||
const ZoomableImage: React.FC<Props> = ({ uri, containerWidth, containerHeight, style }) => {
|
const ZoomableImage: React.FC<Props> = ({ uri, containerWidth, containerHeight, style }) => {
|
||||||
|
// Animated-Werte fuer die Render-Transformation
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
const translateX = useRef(new Animated.Value(0)).current;
|
const tx = useRef(new Animated.Value(0)).current;
|
||||||
const translateY = useRef(new Animated.Value(0)).current;
|
const ty = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
// Aktuelle Werte (Animated.Value lesen ist async, wir tracken parallel)
|
// Logische Zustaende — wir lesen Animated.Value nicht zurueck (waere async)
|
||||||
const current = useRef({ scale: 1, x: 0, y: 0 }).current;
|
const view = useRef({ scale: 1, x: 0, y: 0 }).current;
|
||||||
// State beim Geste-Start (touchStart-Snapshot)
|
|
||||||
const start = useRef({ scale: 1, x: 0, y: 0, distance: 0, focalX: 0, focalY: 0 }).current;
|
|
||||||
// Doppel-Tap-Erkennung
|
|
||||||
const lastTapAt = useRef(0);
|
|
||||||
|
|
||||||
const distance = (touches: any[]) => {
|
// Geste-Snapshot: was war zu Beginn dieser Geste-Phase
|
||||||
const [a, b] = touches;
|
const gesture = useRef({
|
||||||
return Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY);
|
fingers: 0, // aktuelle Finger-Anzahl
|
||||||
|
startScale: 1,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
startDist: 0, // Pinch-Referenz-Distanz
|
||||||
|
startFocalX: 0,
|
||||||
|
startFocalY: 0,
|
||||||
|
movedSinceTouch: 0, // fuer Tap-Erkennung
|
||||||
|
touchStartedAt: 0,
|
||||||
|
touchStartX: 0,
|
||||||
|
touchStartY: 0,
|
||||||
|
}).current;
|
||||||
|
|
||||||
|
// Doppel-Tap
|
||||||
|
const lastTap = useRef({ at: 0, x: 0, y: 0 });
|
||||||
|
|
||||||
|
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||||
|
|
||||||
|
const applyClamped = (s: number, x: number, y: number) => {
|
||||||
|
const ns = clamp(s, MIN_SCALE, MAX_SCALE);
|
||||||
|
// Translation auf das verfuegbare Volumen begrenzen
|
||||||
|
const maxX = Math.max(0, (containerWidth * ns - containerWidth) / 2);
|
||||||
|
const maxY = Math.max(0, (containerHeight * ns - containerHeight) / 2);
|
||||||
|
const nx = clamp(x, -maxX, maxX);
|
||||||
|
const ny = clamp(y, -maxY, maxY);
|
||||||
|
view.scale = ns;
|
||||||
|
view.x = nx;
|
||||||
|
view.y = ny;
|
||||||
|
scale.setValue(ns);
|
||||||
|
tx.setValue(nx);
|
||||||
|
ty.setValue(ny);
|
||||||
};
|
};
|
||||||
|
|
||||||
const focal = (touches: any[]) => {
|
const distance = (touches: any[]) =>
|
||||||
const [a, b] = touches;
|
Math.hypot(touches[0].pageX - touches[1].pageX, touches[0].pageY - touches[1].pageY);
|
||||||
return { x: (a.pageX + b.pageX) / 2, y: (a.pageY + b.pageY) / 2 };
|
|
||||||
|
const focal = (touches: any[]) => ({
|
||||||
|
x: (touches[0].pageX + touches[1].pageX) / 2,
|
||||||
|
y: (touches[0].pageY + touches[1].pageY) / 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Snapshot vor jedem Phasenwechsel (1↔2 Finger) — verhindert Spruenge
|
||||||
|
const snapshot = (touches: any[]) => {
|
||||||
|
gesture.startScale = view.scale;
|
||||||
|
gesture.startX = view.x;
|
||||||
|
gesture.startY = view.y;
|
||||||
|
if (touches.length >= 2) {
|
||||||
|
gesture.startDist = distance(touches);
|
||||||
|
const f = focal(touches);
|
||||||
|
gesture.startFocalX = f.x;
|
||||||
|
gesture.startFocalY = f.y;
|
||||||
|
} else if (touches.length === 1) {
|
||||||
|
gesture.startDist = 0;
|
||||||
|
gesture.startFocalX = touches[0].pageX;
|
||||||
|
gesture.startFocalY = touches[0].pageY;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
|
const responder = useMemo(
|
||||||
|
() =>
|
||||||
const applyAndClamp = (newScale: number, newX: number, newY: number) => {
|
|
||||||
const s = clamp(newScale, 1, 5);
|
|
||||||
// Maximal-Translation: (imgSize * scale - imgSize) / 2
|
|
||||||
const maxX = Math.max(0, (containerWidth * s - containerWidth) / 2);
|
|
||||||
const maxY = Math.max(0, (containerHeight * s - containerHeight) / 2);
|
|
||||||
const x = clamp(newX, -maxX, maxX);
|
|
||||||
const y = clamp(newY, -maxY, maxY);
|
|
||||||
current.scale = s;
|
|
||||||
current.x = x;
|
|
||||||
current.y = y;
|
|
||||||
scale.setValue(s);
|
|
||||||
translateX.setValue(x);
|
|
||||||
translateY.setValue(y);
|
|
||||||
};
|
|
||||||
|
|
||||||
const responder = useRef(
|
|
||||||
PanResponder.create({
|
PanResponder.create({
|
||||||
onStartShouldSetPanResponder: () => true,
|
onStartShouldSetPanResponder: () => true,
|
||||||
|
onStartShouldSetPanResponderCapture: () => true,
|
||||||
onMoveShouldSetPanResponder: () => true,
|
onMoveShouldSetPanResponder: () => true,
|
||||||
onPanResponderGrant: (_e, gestureState) => {
|
onMoveShouldSetPanResponderCapture: () => true,
|
||||||
const touches = gestureState as any;
|
|
||||||
const t = touches.numberActiveTouches || 1;
|
onPanResponderGrant: (e: GestureResponderEvent) => {
|
||||||
// Doppel-Tap-Erkennung (nur bei 1 Finger)
|
const touches = e.nativeEvent.touches as any[];
|
||||||
if (t === 1) {
|
gesture.fingers = touches.length;
|
||||||
const now = Date.now();
|
gesture.movedSinceTouch = 0;
|
||||||
if (now - lastTapAt.current < 280) {
|
gesture.touchStartedAt = Date.now();
|
||||||
// Doppel-Tap → Zoom-Toggle
|
gesture.touchStartX = touches[0]?.pageX ?? 0;
|
||||||
if (current.scale > 1.1) {
|
gesture.touchStartY = touches[0]?.pageY ?? 0;
|
||||||
applyAndClamp(1, 0, 0);
|
snapshot(touches);
|
||||||
} else {
|
},
|
||||||
applyAndClamp(2.5, 0, 0);
|
|
||||||
}
|
onPanResponderMove: (e: GestureResponderEvent, _gs) => {
|
||||||
lastTapAt.current = 0;
|
const touches = e.nativeEvent.touches as any[];
|
||||||
|
|
||||||
|
// Phasenwechsel? → Re-Snapshot, damit nicht gesprungen wird
|
||||||
|
if (touches.length !== gesture.fingers) {
|
||||||
|
gesture.fingers = touches.length;
|
||||||
|
snapshot(touches);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastTapAt.current = now;
|
|
||||||
}
|
gesture.movedSinceTouch += 1;
|
||||||
start.scale = current.scale;
|
|
||||||
start.x = current.x;
|
|
||||||
start.y = current.y;
|
|
||||||
},
|
|
||||||
onPanResponderMove: (e, gestureState) => {
|
|
||||||
const touches = e.nativeEvent.touches;
|
|
||||||
if (touches.length >= 2) {
|
if (touches.length >= 2) {
|
||||||
// Pinch + Pan
|
// Pinch + Pan via Focal
|
||||||
if (start.distance === 0) {
|
const d = distance(touches);
|
||||||
// Initialisiere die Pinch-Referenz beim Uebergang 1→2 Finger
|
if (gesture.startDist === 0) {
|
||||||
start.distance = distance(touches);
|
// Sicherheitsnetz falls Snapshot gemissed wurde
|
||||||
const f = focal(touches);
|
snapshot(touches);
|
||||||
start.focalX = f.x;
|
|
||||||
start.focalY = f.y;
|
|
||||||
start.scale = current.scale;
|
|
||||||
start.x = current.x;
|
|
||||||
start.y = current.y;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newDistance = distance(touches);
|
const factor = d / gesture.startDist;
|
||||||
const newFocal = focal(touches);
|
const f = focal(touches);
|
||||||
const scaleFactor = newDistance / start.distance;
|
const newScale = clamp(gesture.startScale * factor, MIN_SCALE, MAX_SCALE);
|
||||||
const newScale = clamp(start.scale * scaleFactor, 1, 5);
|
// Focal-basierter Pan: zoomt um den Mittelpunkt der zwei Finger
|
||||||
// Pan-Anteil aus Focal-Bewegung
|
const newX = gesture.startX + (f.x - gesture.startFocalX);
|
||||||
const newX = start.x + (newFocal.x - start.focalX);
|
const newY = gesture.startY + (f.y - gesture.startFocalY);
|
||||||
const newY = start.y + (newFocal.y - start.focalY);
|
applyClamped(newScale, newX, newY);
|
||||||
applyAndClamp(newScale, newX, newY);
|
} else if (touches.length === 1 && view.scale > 1.02) {
|
||||||
} else if (touches.length === 1 && current.scale > 1.05) {
|
const dx = touches[0].pageX - gesture.startFocalX;
|
||||||
// Single-Finger-Pan nur wenn gezoomt
|
const dy = touches[0].pageY - gesture.startFocalY;
|
||||||
start.distance = 0; // Reset Pinch-Tracking
|
if (Math.abs(dx) < PAN_SLOP_AT_SCALE_1 && Math.abs(dy) < PAN_SLOP_AT_SCALE_1) return;
|
||||||
applyAndClamp(current.scale, start.x + gestureState.dx, start.y + gestureState.dy);
|
applyClamped(view.scale, gesture.startX + dx, gesture.startY + dy);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPanResponderRelease: () => { start.distance = 0; },
|
|
||||||
onPanResponderTerminate: () => { start.distance = 0; },
|
onPanResponderRelease: (e: GestureResponderEvent) => {
|
||||||
|
const elapsed = Date.now() - gesture.touchStartedAt;
|
||||||
|
const dx = (e.nativeEvent.changedTouches?.[0]?.pageX ?? gesture.touchStartX) - gesture.touchStartX;
|
||||||
|
const dy = (e.nativeEvent.changedTouches?.[0]?.pageY ?? gesture.touchStartY) - gesture.touchStartY;
|
||||||
|
const wasTap =
|
||||||
|
elapsed < 280 &&
|
||||||
|
Math.abs(dx) < DOUBLE_TAP_DIST &&
|
||||||
|
Math.abs(dy) < DOUBLE_TAP_DIST;
|
||||||
|
if (wasTap) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTap.current.at < DOUBLE_TAP_MS) {
|
||||||
|
// Doppel-Tap → Zoom-Toggle
|
||||||
|
if (view.scale > 1.1) {
|
||||||
|
applyClamped(1, 0, 0);
|
||||||
|
} else {
|
||||||
|
applyClamped(2.5, 0, 0);
|
||||||
|
}
|
||||||
|
lastTap.current = { at: 0, x: 0, y: 0 };
|
||||||
|
} else {
|
||||||
|
lastTap.current = { at: now, x: gesture.touchStartX, y: gesture.touchStartY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gesture.fingers = 0;
|
||||||
|
gesture.startDist = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
onPanResponderTerminate: () => {
|
||||||
|
gesture.fingers = 0;
|
||||||
|
gesture.startDist = 0;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
).current;
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={StyleSheet.absoluteFill} {...responder.panHandlers}>
|
<View
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
collapsable={false}
|
||||||
|
{...responder.panHandlers}
|
||||||
|
>
|
||||||
|
<Animated.View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
||||||
<Animated.Image
|
<Animated.Image
|
||||||
source={{ uri }}
|
source={{ uri }}
|
||||||
style={[
|
style={[
|
||||||
style,
|
style,
|
||||||
{
|
{
|
||||||
transform: [
|
transform: [{ translateX: tx }, { translateY: ty }, { scale }],
|
||||||
{ translateX },
|
|
||||||
{ translateY },
|
|
||||||
{ scale },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ interface Attachment {
|
|||||||
uri?: string; // Lokaler Pfad (file://) fuer Anzeige
|
uri?: string; // Lokaler Pfad (file://) fuer Anzeige
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
serverPath?: string; // Pfad auf dem Server (/shared/uploads/...) fuer Re-Download
|
serverPath?: string; // Pfad auf dem Server (/shared/uploads/...) fuer Re-Download
|
||||||
|
deleted?: boolean; // Datei wurde nachtraeglich geloescht (Diagnostic-Manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
@@ -70,6 +71,14 @@ interface ChatMessage {
|
|||||||
* gespiegelt damit wir die EXAKT richtige Placeholder-Bubble ersetzen,
|
* gespiegelt damit wir die EXAKT richtige Placeholder-Bubble ersetzen,
|
||||||
* auch wenn mehrere Aufnahmen parallel offen sind. */
|
* auch wenn mehrere Aufnahmen parallel offen sind. */
|
||||||
audioRequestId?: string;
|
audioRequestId?: string;
|
||||||
|
/** Skill-Created-Bubble: ARIA hat einen neuen Skill angelegt */
|
||||||
|
skillCreated?: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
execution: string;
|
||||||
|
active: boolean;
|
||||||
|
setupError?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Konstanten ---
|
// --- Konstanten ---
|
||||||
@@ -386,6 +395,41 @@ const ChatScreen: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skill_created: ARIA hat einen neuen Skill angelegt → eigene Bubble
|
||||||
|
if (message.type === 'skill_created') {
|
||||||
|
const p = (message.payload || {}) as any;
|
||||||
|
const skillMsg: ChatMessage = {
|
||||||
|
id: nextId(),
|
||||||
|
sender: 'aria',
|
||||||
|
text: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
skillCreated: {
|
||||||
|
name: String(p.name || '(unbenannt)'),
|
||||||
|
description: String(p.description || ''),
|
||||||
|
execution: String(p.execution || 'bash'),
|
||||||
|
active: p.active !== false,
|
||||||
|
setupError: p.setup_error ? String(p.setup_error) : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setMessages(prev => capMessages([...prev, skillMsg]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// file_deleted: Datei wurde geloescht (vom Diagnostic User) → Bubble updaten
|
||||||
|
if (message.type === 'file_deleted') {
|
||||||
|
const p = (message.payload?.path as string) || '';
|
||||||
|
if (!p) return;
|
||||||
|
setMessages(prev => prev.map(m => ({
|
||||||
|
...m,
|
||||||
|
attachments: m.attachments?.map(a =>
|
||||||
|
a.serverPath === p ? { ...a, deleted: true } : a
|
||||||
|
),
|
||||||
|
})));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// file_list_response: wird vom Datei-Manager im SettingsScreen verarbeitet.
|
||||||
|
|
||||||
// file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen
|
// file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen
|
||||||
if (message.type === 'file_from_aria') {
|
if (message.type === 'file_from_aria') {
|
||||||
const p = message.payload || {};
|
const p = message.payload || {};
|
||||||
@@ -1038,12 +1082,41 @@ const ChatScreen: React.FC = () => {
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Spezial-Bubble: ARIA hat einen Skill erstellt
|
||||||
|
if (item.skillCreated) {
|
||||||
|
const s = item.skillCreated;
|
||||||
|
return (
|
||||||
|
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}]}>
|
||||||
|
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
|
||||||
|
{'🛠 ARIA hat einen neuen Skill erstellt'}
|
||||||
|
</Text>
|
||||||
|
<Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
|
||||||
|
<Text style={{fontWeight: 'bold'}}>{s.name}</Text>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12}}>{` (${s.execution}, ${s.active ? 'aktiv' : 'deaktiviert'})`}</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12, marginTop: 2}}>{s.description}</Text>
|
||||||
|
{s.setupError && (
|
||||||
|
<Text style={{color: '#FF6B6B', fontSize: 11, marginTop: 4}}>
|
||||||
|
{'⚠ Setup-Fehler: '}{s.setupError.slice(0, 200)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Skill · {time}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble]}>
|
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble]}>
|
||||||
{/* Anhang-Vorschau */}
|
{/* Anhang-Vorschau */}
|
||||||
{item.attachments?.map((att, idx) => (
|
{item.attachments?.map((att, idx) => (
|
||||||
<View key={idx}>
|
<View key={idx}>
|
||||||
{att.type === 'image' && att.uri ? (
|
{att.deleted ? (
|
||||||
|
<View style={[styles.attachmentFile, {opacity: 0.6}]}>
|
||||||
|
<Text style={styles.attachmentFileIcon}>{'🗑️'}</Text>
|
||||||
|
<Text style={[styles.attachmentFileName, {textDecorationLine: 'line-through'}]} numberOfLines={1}>{att.name}</Text>
|
||||||
|
<Text style={[styles.attachmentFileSize, {color: '#FF9500'}]}>(geloescht)</Text>
|
||||||
|
</View>
|
||||||
|
) : att.type === 'image' && att.uri ? (
|
||||||
<ChatImage
|
<ChatImage
|
||||||
uri={att.uri}
|
uri={att.uri}
|
||||||
onPress={() => setFullscreenImage(att.uri || null)}
|
onPress={() => setFullscreenImage(att.uri || null)}
|
||||||
@@ -1253,24 +1326,6 @@ const ChatScreen: React.FC = () => {
|
|||||||
: '\uD83D\uDCAD ARIA denkt...'}
|
: '\uD83D\uDCAD ARIA denkt...'}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{flexDirection: 'row', gap: 6}}>
|
<View style={{flexDirection: 'row', gap: 6}}>
|
||||||
<TouchableOpacity style={[styles.thinkingCancel, {borderColor: '#FF9500'}]} onPress={() => rvs.send('doctor_fix' as any, {})}>
|
|
||||||
<Text style={[styles.thinkingCancelText, {color: '#FF9500'}]}>{'🔧'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.thinkingCancel, {borderColor: '#FF3B30'}]}
|
|
||||||
onPress={() => {
|
|
||||||
Alert.alert(
|
|
||||||
'ARIA hart neu starten?',
|
|
||||||
'Container-Restart (~15s). Laufende Anfragen gehen verloren.',
|
|
||||||
[
|
|
||||||
{ text: 'Abbrechen', style: 'cancel' },
|
|
||||||
{ text: 'Neu starten', style: 'destructive', onPress: () => rvs.send('aria_restart' as any, {}) },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={[styles.thinkingCancelText, {color: '#FF3B30'}]}>{'🚨'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity style={styles.thinkingCancel} onPress={cancelRequest}>
|
<TouchableOpacity style={styles.thinkingCancel} onPress={cancelRequest}>
|
||||||
<Text style={styles.thinkingCancelText}>Abbrechen</Text>
|
<Text style={styles.thinkingCancelText}>Abbrechen</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ const SETTINGS_SECTIONS = [
|
|||||||
{ id: 'wake_word', icon: '👂', label: 'Wake-Word', desc: 'Wake-Word-Auswahl' },
|
{ id: 'wake_word', icon: '👂', label: 'Wake-Word', desc: 'Wake-Word-Auswahl' },
|
||||||
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
|
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
|
||||||
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
||||||
|
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
||||||
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
|
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
|
||||||
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -147,6 +148,13 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [xttsVoice, setXttsVoice] = useState('');
|
const [xttsVoice, setXttsVoice] = useState('');
|
||||||
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
|
||||||
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
|
const [availableVoices, setAvailableVoices] = useState<Array<{name: string, size: number}>>([]);
|
||||||
|
// Datei-Manager
|
||||||
|
const [fileManagerOpen, setFileManagerOpen] = useState(false);
|
||||||
|
const [fileManagerFiles, setFileManagerFiles] = useState<Array<{name: string; path: string; size: number; mtime: number; fromAria: boolean}>>([]);
|
||||||
|
const [fileManagerLoading, setFileManagerLoading] = useState(false);
|
||||||
|
const [fileManagerError, setFileManagerError] = useState('');
|
||||||
|
const [fileManagerSearch, setFileManagerSearch] = useState('');
|
||||||
|
const [fileManagerFilter, setFileManagerFilter] = useState<'all' | 'aria' | 'user'>('all');
|
||||||
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
|
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
|
||||||
const [tempPath, setTempPath] = useState('');
|
const [tempPath, setTempPath] = useState('');
|
||||||
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
|
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
|
||||||
@@ -371,6 +379,25 @@ const SettingsScreen: React.FC = () => {
|
|||||||
setAvailableVoices(voices);
|
setAvailableVoices(voices);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Datei-Manager: Liste empfangen
|
||||||
|
if (message.type === ('file_list_response' as any)) {
|
||||||
|
const p: any = message.payload || {};
|
||||||
|
if (p.ok) {
|
||||||
|
setFileManagerFiles(p.files || []);
|
||||||
|
} else {
|
||||||
|
setFileManagerError(p.error || 'Unbekannter Fehler');
|
||||||
|
}
|
||||||
|
setFileManagerLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei-Manager: Datei wurde geloescht (vom Diagnostic oder dieser App)
|
||||||
|
if (message.type === ('file_deleted' as any)) {
|
||||||
|
const p: any = message.payload || {};
|
||||||
|
if (p.path) {
|
||||||
|
setFileManagerFiles(prev => prev.filter(f => f.path !== p.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen
|
// Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen
|
||||||
if (message.type === ('xtts_voice_saved' as any)) {
|
if (message.type === ('xtts_voice_saved' as any)) {
|
||||||
const name = (message.payload as any).name as string;
|
const name = (message.payload as any).name as string;
|
||||||
@@ -564,6 +591,119 @@ const SettingsScreen: React.FC = () => {
|
|||||||
visible={voiceCloneVisible}
|
visible={voiceCloneVisible}
|
||||||
onClose={() => setVoiceCloneVisible(false)}
|
onClose={() => setVoiceCloneVisible(false)}
|
||||||
/>
|
/>
|
||||||
|
{/* Datei-Manager Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={fileManagerOpen}
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setFileManagerOpen(false)}
|
||||||
|
>
|
||||||
|
<View style={{flex:1, backgroundColor:'#080810', paddingTop:24}}>
|
||||||
|
<View style={{flexDirection:'row', alignItems:'center', padding:12, borderBottomWidth:1, borderColor:'#1E1E2E'}}>
|
||||||
|
<TouchableOpacity onPress={() => setFileManagerOpen(false)} style={{padding:8}}>
|
||||||
|
<Text style={{color:'#0096FF', fontSize:24}}>‹</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={{color:'#E0E0F0', fontSize:18, fontWeight:'600', flex:1, marginLeft:8}}>Dateien</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setFileManagerError('');
|
||||||
|
setFileManagerLoading(true);
|
||||||
|
rvs.send('file_list_request' as any, {});
|
||||||
|
}}
|
||||||
|
style={{padding:8}}
|
||||||
|
>
|
||||||
|
<Text style={{color:'#0096FF', fontSize:14}}>🔄</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={{padding:12}}>
|
||||||
|
<TextInput
|
||||||
|
style={{backgroundColor:'#1E1E2E', borderRadius:8, padding:10, color:'#E0E0F0', fontSize:14}}
|
||||||
|
placeholder="Suche..."
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
value={fileManagerSearch}
|
||||||
|
onChangeText={setFileManagerSearch}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<View style={{flexDirection:'row', marginTop:8, gap:6}}>
|
||||||
|
{(['all','aria','user'] as const).map(f => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={f}
|
||||||
|
onPress={() => setFileManagerFilter(f)}
|
||||||
|
style={{
|
||||||
|
paddingVertical:6, paddingHorizontal:12, borderRadius:14,
|
||||||
|
backgroundColor: fileManagerFilter === f ? '#0096FF' : '#1E1E2E',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{color: fileManagerFilter === f ? '#fff' : '#8888AA', fontSize:12}}>
|
||||||
|
{f === 'all' ? 'Alle' : f === 'aria' ? 'Von ARIA' : 'Von dir'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{fileManagerLoading ? (
|
||||||
|
<Text style={{color:'#8888AA', textAlign:'center', marginTop:20}}>Lade...</Text>
|
||||||
|
) : fileManagerError ? (
|
||||||
|
<Text style={{color:'#FF6B6B', textAlign:'center', marginTop:20}}>{fileManagerError}</Text>
|
||||||
|
) : (
|
||||||
|
<ScrollView style={{flex:1}} contentContainerStyle={{padding:12}}>
|
||||||
|
{(() => {
|
||||||
|
let files = fileManagerFiles;
|
||||||
|
if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria);
|
||||||
|
else if (fileManagerFilter === 'user') files = files.filter(f => !f.fromAria);
|
||||||
|
if (fileManagerSearch) {
|
||||||
|
const q = fileManagerSearch.toLowerCase();
|
||||||
|
files = files.filter(f => f.name.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
if (!files.length) {
|
||||||
|
return <Text style={{color:'#555570', textAlign:'center', marginTop:20}}>Keine Dateien</Text>;
|
||||||
|
}
|
||||||
|
const fmtSize = (b: number) => b < 1024 ? `${b} B` : b < 1024*1024 ? `${(b/1024).toFixed(1)} KB` : `${(b/1024/1024).toFixed(1)} MB`;
|
||||||
|
return files.map(f => (
|
||||||
|
<View key={f.path} style={{
|
||||||
|
backgroundColor:'#0D0D1A', padding:12, borderRadius:8, marginBottom:8,
|
||||||
|
flexDirection:'row', alignItems:'center', gap:8,
|
||||||
|
}}>
|
||||||
|
<View style={{flex:1}}>
|
||||||
|
<View style={{flexDirection:'row', alignItems:'center'}}>
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: f.fromAria ? '#0096FF22' : '#34C75922',
|
||||||
|
paddingHorizontal:6, paddingVertical:1, borderRadius:3, marginRight:6,
|
||||||
|
}}>
|
||||||
|
<Text style={{color: f.fromAria ? '#0096FF' : '#34C759', fontSize:9}}>
|
||||||
|
{f.fromAria ? 'ARIA' : 'USER'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{color:'#E0E0F0', fontSize:13, flex:1}} numberOfLines={1}>{f.name}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{color:'#555570', fontSize:10, marginTop:2}}>
|
||||||
|
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
Alert.alert(
|
||||||
|
'Datei löschen?',
|
||||||
|
`"${f.name}"\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`,
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{ text: 'Löschen', style: 'destructive', onPress: () => {
|
||||||
|
rvs.send('file_delete_request' as any, { path: f.path });
|
||||||
|
ToastAndroid.show('Lösch-Befehl gesendet…', ToastAndroid.SHORT);
|
||||||
|
}},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
style={{padding:8}}
|
||||||
|
>
|
||||||
|
<Text style={{color:'#FF6B6B', fontSize:18}}>🗑</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
|
||||||
{currentSection === null && (
|
{currentSection === null && (
|
||||||
@@ -1288,76 +1428,66 @@ const SettingsScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* === ARIA Reparatur === */}
|
{/* === Reparatur === */}
|
||||||
<Text style={[styles.sectionTitle, {marginTop: 16}]}>Reparatur</Text>
|
<Text style={[styles.sectionTitle, {marginTop: 16}]}>Reparatur</Text>
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.toggleHint}>
|
<Text style={styles.toggleHint}>
|
||||||
Wenn ARIA gar nicht mehr antwortet oder auf jede Anfrage mit
|
Container gezielt neu starten — wenn die Voice-Bridge, das Gehirn
|
||||||
"Antwort ohne Text" zurueckkommt — meistens ein steckengebliebener
|
oder die Vector-DB haengt. Restart dauert wenige Sekunden,
|
||||||
Run im aria-core. Dieser Button fuehrt {'“'}openclaw doctor --fix{'”'}
|
laufende Anfragen gehen verloren.
|
||||||
aus und macht ARIA wieder ansprechbar.
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{[
|
||||||
|
{ name: 'aria-bridge', label: '🚨 aria-bridge neu (Voice + RVS)' },
|
||||||
|
{ name: 'aria-brain', label: '🚨 aria-brain neu (Agent + Memory)' },
|
||||||
|
{ name: 'aria-qdrant', label: '🚨 aria-qdrant neu (Vector-DB)' },
|
||||||
|
].map(c => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,149,0,0.15)'}]}
|
key={c.name}
|
||||||
onPress={() => {
|
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.10)'}]}
|
||||||
rvs.send('doctor_fix' as any, {});
|
|
||||||
ToastAndroid.show('Reparatur-Befehl gesendet — Antwort kommt gleich', ToastAndroid.SHORT);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={[styles.clearButtonText, {color: '#FF9500'}]}>{'🔧 ARIA reparieren'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={[styles.toggleHint, {marginTop: 12}]}>
|
|
||||||
Wenn auch Reparieren nicht hilft — Container hart neu starten.
|
|
||||||
ARIA ist dann ~15 Sekunden weg und kommt mit frischem State zurueck.
|
|
||||||
Laufende Anfragen gehen verloren.
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,59,48,0.15)'}]}
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'ARIA hart neu starten?',
|
`${c.name} neu starten?`,
|
||||||
'Container-Restart (~15s). Laufende Anfragen gehen verloren.',
|
'Restart in wenigen Sekunden. Laufende Anfragen gehen verloren.',
|
||||||
[
|
[
|
||||||
{ text: 'Abbrechen', style: 'cancel' },
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
{ text: 'Neu starten', style: 'destructive', onPress: () => {
|
{ text: 'Neu starten', style: 'destructive', onPress: () => {
|
||||||
rvs.send('aria_restart' as any, {});
|
rvs.send('container_restart' as any, { name: c.name });
|
||||||
ToastAndroid.show('Container-Restart angestossen…', ToastAndroid.LONG);
|
ToastAndroid.show(`${c.name} wird neu gestartet…`, ToastAndroid.LONG);
|
||||||
}},
|
}},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>{'🚨 ARIA hart neu starten'}</Text>
|
<Text style={[styles.clearButtonText, {color: '#FF3B30'}]}>{c.label}</Text>
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={[styles.toggleHint, {marginTop: 12}]}>
|
|
||||||
Konversation komplett zuruecksetzen — alle bisherigen Nachrichten
|
|
||||||
aus ARIA's Session loeschen + Container neu. Anders als der harte
|
|
||||||
Restart wird hier auch ARIA's Erinnerung an die laufende
|
|
||||||
Konversation gewipt. Geschieht automatisch alle 140 Nachrichten
|
|
||||||
(Bridge-Setting COMPACT_AFTER_MESSAGES).
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(255,149,0,0.15)'}]}
|
|
||||||
onPress={() => {
|
|
||||||
Alert.alert(
|
|
||||||
'Konversation komprimieren?',
|
|
||||||
'Alle Nachrichten in ARIAs aktueller Session werden geloescht und der Container neu gestartet. ARIA vergisst den bisherigen Gespraechsverlauf.',
|
|
||||||
[
|
|
||||||
{ text: 'Abbrechen', style: 'cancel' },
|
|
||||||
{ text: 'Komprimieren', style: 'destructive', onPress: () => {
|
|
||||||
rvs.send('aria_session_reset' as any, {});
|
|
||||||
ToastAndroid.show('Session wird zurueckgesetzt…', ToastAndroid.LONG);
|
|
||||||
}},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={[styles.clearButtonText, {color: '#FF9500'}]}>{'🧹 Konversation komprimieren'}</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
{/* === Datei-Manager === */}
|
||||||
|
{currentSection === 'files' && (<>
|
||||||
|
<Text style={styles.sectionTitle}>Dateien</Text>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Alle Dateien aus <Text style={{fontFamily:'monospace'}}>/shared/uploads/</Text>
|
||||||
|
— was ARIA generiert hat und was du hochgeladen hast.
|
||||||
|
Beim Löschen wird die Bubble in App + Diagnostic als gelöscht markiert.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.clearButton, {marginTop: 8, backgroundColor: 'rgba(0,150,255,0.15)'}]}
|
||||||
|
onPress={() => {
|
||||||
|
setFileManagerError('');
|
||||||
|
setFileManagerLoading(true);
|
||||||
|
setFileManagerOpen(true);
|
||||||
|
rvs.send('file_list_request' as any, {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={[styles.clearButtonText, {color: '#0096FF'}]}>{'📂 Datei-Manager öffnen'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</>)}
|
||||||
|
|
||||||
{/* === Logs === */}
|
{/* === Logs === */}
|
||||||
{currentSection === 'protocol' && (<>
|
{currentSection === 'protocol' && (<>
|
||||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||||
|
|||||||
Reference in New Issue
Block a user