dc2f4eb6d2
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>
225 lines
7.6 KiB
TypeScript
225 lines
7.6 KiB
TypeScript
/**
|
|
* ZoomableImage — Pinch-to-Zoom + Pan fuers Vollbild-Modal.
|
|
*
|
|
* Reine RN-Implementation, ohne react-native-gesture-handler.
|
|
*
|
|
* - 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, { useMemo, useRef } from 'react';
|
|
import {
|
|
Animated,
|
|
PanResponder,
|
|
GestureResponderEvent,
|
|
ImageStyle,
|
|
StyleProp,
|
|
StyleSheet,
|
|
View,
|
|
} from 'react-native';
|
|
|
|
interface Props {
|
|
uri: string;
|
|
containerWidth: number;
|
|
containerHeight: number;
|
|
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 tx = useRef(new Animated.Value(0)).current;
|
|
const ty = useRef(new Animated.Value(0)).current;
|
|
|
|
// Logische Zustaende — wir lesen Animated.Value nicht zurueck (waere async)
|
|
const view = useRef({ scale: 1, x: 0, y: 0 }).current;
|
|
|
|
// 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 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 responder = useMemo(
|
|
() =>
|
|
PanResponder.create({
|
|
onStartShouldSetPanResponder: () => true,
|
|
onStartShouldSetPanResponderCapture: () => true,
|
|
onMoveShouldSetPanResponder: () => true,
|
|
onMoveShouldSetPanResponderCapture: () => true,
|
|
|
|
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);
|
|
},
|
|
|
|
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;
|
|
}
|
|
const factor = d / gesture.startDist;
|
|
const f = focal(touches);
|
|
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);
|
|
}
|
|
},
|
|
|
|
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}
|
|
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>
|
|
);
|
|
};
|
|
|
|
export default ZoomableImage;
|