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:
2026-05-11 22:24:06 +02:00
parent 0f9a029269
commit dc2f4eb6d2
3 changed files with 457 additions and 190 deletions
+191 -109
View File
@@ -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:
* - 1 Finger: Pan wenn schon gezoomt
* - 2 Finger: Pinch fuer Zoom + Pan
* - Doppel-Tap: Toggle 1x ↔ 2.5x Zoom
* Reine RN-Implementation, ohne react-native-gesture-handler.
*
* Scale wird auf [1, 5] gecapped, Translation auf das verfuegbare
* Image-Volumen (kein Out-of-bounds-Pan).
* - 2 Finger: Pinch (Zoom 1x..5x) + simultaner Pan via Focal-Punkt
* - 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 { Animated, PanResponder, View, StyleSheet, ImageStyle, StyleProp } from 'react-native';
import React, { useMemo, useRef } from 'react';
import {
Animated,
PanResponder,
GestureResponderEvent,
ImageStyle,
StyleProp,
StyleSheet,
View,
} from 'react-native';
interface Props {
uri: string;
@@ -20,121 +37,186 @@ interface Props {
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 }) => {
// Animated-Werte fuer die Render-Transformation
const scale = useRef(new Animated.Value(1)).current;
const translateX = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(0)).current;
const tx = useRef(new Animated.Value(0)).current;
const ty = useRef(new Animated.Value(0)).current;
// Aktuelle Werte (Animated.Value lesen ist async, wir tracken parallel)
const current = 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);
// Logische Zustaende — wir lesen Animated.Value nicht zurueck (waere async)
const view = useRef({ scale: 1, x: 0, y: 0 }).current;
const distance = (touches: any[]) => {
const [a, b] = touches;
return Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY);
// Geste-Snapshot: was war zu Beginn dieser Geste-Phase
const gesture = useRef({
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 [a, b] = touches;
return { x: (a.pageX + b.pageX) / 2, y: (a.pageY + b.pageY) / 2 };
const distance = (touches: any[]) =>
Math.hypot(touches[0].pageX - touches[1].pageX, touches[0].pageY - touches[1].pageY);
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(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: () => true,
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);
};
onPanResponderGrant: (e: GestureResponderEvent) => {
const touches = e.nativeEvent.touches as any[];
gesture.fingers = touches.length;
gesture.movedSinceTouch = 0;
gesture.touchStartedAt = Date.now();
gesture.touchStartX = touches[0]?.pageX ?? 0;
gesture.touchStartY = touches[0]?.pageY ?? 0;
snapshot(touches);
},
const responder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (_e, gestureState) => {
const touches = gestureState as any;
const t = touches.numberActiveTouches || 1;
// Doppel-Tap-Erkennung (nur bei 1 Finger)
if (t === 1) {
const now = Date.now();
if (now - lastTapAt.current < 280) {
// Doppel-Tap → Zoom-Toggle
if (current.scale > 1.1) {
applyAndClamp(1, 0, 0);
} else {
applyAndClamp(2.5, 0, 0);
onPanResponderMove: (e: GestureResponderEvent, _gs) => {
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;
}
gesture.movedSinceTouch += 1;
if (touches.length >= 2) {
// Pinch + Pan via Focal
const d = distance(touches);
if (gesture.startDist === 0) {
// Sicherheitsnetz falls Snapshot gemissed wurde
snapshot(touches);
return;
}
lastTapAt.current = 0;
return;
}
lastTapAt.current = now;
}
start.scale = current.scale;
start.x = current.x;
start.y = current.y;
},
onPanResponderMove: (e, gestureState) => {
const touches = e.nativeEvent.touches;
if (touches.length >= 2) {
// Pinch + Pan
if (start.distance === 0) {
// Initialisiere die Pinch-Referenz beim Uebergang 1→2 Finger
start.distance = distance(touches);
const factor = d / gesture.startDist;
const f = focal(touches);
start.focalX = f.x;
start.focalY = f.y;
start.scale = current.scale;
start.x = current.x;
start.y = current.y;
return;
const newScale = clamp(gesture.startScale * factor, MIN_SCALE, MAX_SCALE);
// Focal-basierter Pan: zoomt um den Mittelpunkt der zwei Finger
const newX = gesture.startX + (f.x - gesture.startFocalX);
const newY = gesture.startY + (f.y - gesture.startFocalY);
applyClamped(newScale, newX, newY);
} else if (touches.length === 1 && view.scale > 1.02) {
const dx = touches[0].pageX - gesture.startFocalX;
const dy = touches[0].pageY - gesture.startFocalY;
if (Math.abs(dx) < PAN_SLOP_AT_SCALE_1 && Math.abs(dy) < PAN_SLOP_AT_SCALE_1) return;
applyClamped(view.scale, gesture.startX + dx, gesture.startY + dy);
}
const newDistance = distance(touches);
const newFocal = focal(touches);
const scaleFactor = newDistance / start.distance;
const newScale = clamp(start.scale * scaleFactor, 1, 5);
// Pan-Anteil aus Focal-Bewegung
const newX = start.x + (newFocal.x - start.focalX);
const newY = start.y + (newFocal.y - start.focalY);
applyAndClamp(newScale, newX, newY);
} else if (touches.length === 1 && current.scale > 1.05) {
// Single-Finger-Pan nur wenn gezoomt
start.distance = 0; // Reset Pinch-Tracking
applyAndClamp(current.scale, start.x + gestureState.dx, start.y + gestureState.dy);
}
},
onPanResponderRelease: () => { start.distance = 0; },
onPanResponderTerminate: () => { start.distance = 0; },
}),
).current;
},
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;
},
}),
[],
);
return (
<View style={StyleSheet.absoluteFill} {...responder.panHandlers}>
<Animated.Image
source={{ uri }}
style={[
style,
{
transform: [
{ translateX },
{ translateY },
{ scale },
],
},
]}
resizeMode="contain"
/>
<View
style={StyleSheet.absoluteFill}
collapsable={false}
{...responder.panHandlers}
>
<Animated.View pointerEvents="none" style={StyleSheet.absoluteFill}>
<Animated.Image
source={{ uri }}
style={[
style,
{
transform: [{ translateX: tx }, { translateY: ty }, { scale }],
},
]}
resizeMode="contain"
/>
</Animated.View>
</View>
);
};