diff --git a/android/App.tsx b/android/App.tsx index cd71fbb..8b4a64b 100644 --- a/android/App.tsx +++ b/android/App.tsx @@ -12,7 +12,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { NavigationContainer, DefaultTheme } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import ChatScreen from './src/screens/ChatScreen'; +import WorkspaceScreen from './src/workspace/WorkspaceScreen'; import SettingsScreen from './src/screens/SettingsScreen'; import rvs from './src/services/rvs'; import { initLogger, installGlobalCrashReporter } from './src/services/logger'; @@ -166,7 +166,7 @@ const App: React.FC = () => { > void; +} + +const Tile: React.FC = ({ id, rect, subtitle, onFocus }) => { + const meta = TILE_META[id]; + const tap = Gesture.Tap() + .maxDuration(300) + .onEnd((_e, success) => { + if (success) runOnJS(onFocus)(id); + }); + + return ( + + + {meta.icon} + {meta.title} + {!!subtitle && {subtitle}} + Tippen zum Öffnen + + + ); +}; + +const styles = StyleSheet.create({ + tile: { + position: 'absolute', + backgroundColor: '#12122A', + borderRadius: 32, + borderWidth: 3, + borderColor: '#1E1E2E', + alignItems: 'center', + justifyContent: 'center', + padding: 40, + }, + icon: { fontSize: 220, marginBottom: 24 }, + title: { color: '#FFFFFF', fontSize: 96, fontWeight: '800' }, + subtitle: { color: '#9090B0', fontSize: 52, marginTop: 20, textAlign: 'center' }, + hint: { color: '#555570', fontSize: 46, marginTop: 40 }, +}); + +export default Tile; diff --git a/android/src/workspace/WorkspaceCanvas.tsx b/android/src/workspace/WorkspaceCanvas.tsx new file mode 100644 index 0000000..4ba8460 --- /dev/null +++ b/android/src/workspace/WorkspaceCanvas.tsx @@ -0,0 +1,226 @@ +/** + * WorkspaceCanvas — die zoom-/verschiebbare "Landkarte" plus Fokus-Modus. + * + * Zwei Ebenen uebereinander: + * 1. Welt-Ebene (skaliert/verschoben): nur leichte Thumbnail-Kacheln. Hier + * wirken 2-Finger-Pinch-Zoom + 2-Finger-Pan (nur in der Uebersicht). + * 2. Identity-Content-Ebene (Scale 1, nie transformiert): die schweren, + * interaktiven Inhalte (ChatScreen + WebViews). Alle sichtbaren Kacheln + * sind hier IMMER gemountet; nur die fokussierte ist per display sichtbar. + * Dadurch bleiben Touch-Koordinaten/Keyboard korrekt und nichts remountet + * beim Fokuswechsel. + * + * Tap auf eine Thumbnail-Kachel → Fokus (voll aufgezoomt + interaktiv). Der + * "⤢ Übersicht"-Button bzw. der Hardware-Back fuehren zurueck zur Landkarte. + * Bei nur einer Kachel (reiner Chat) ist diese dauerhaft fokussiert und der + * Canvas verhaelt sich exakt wie der bisherige Vollbild-Chat. + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { BackHandler, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; + +import { + boundsOf, Camera, focusCamera, overviewCamera, TileId, TILE_RECTS, toLocal, +} from './layout'; +import Tile from './Tile'; +import ChatTile from './tiles/ChatTile'; +import CodeEditorTile from './tiles/CodeEditorTile'; +import VncTile from './tiles/VncTile'; +import PreviewTile from './tiles/PreviewTile'; + +interface Props { + projectId: string; + visibleTiles: TileId[]; + subtitles?: Partial>; +} + +const ANIM = { duration: 260 }; +const MIN_SCALE = 0.15; +const MAX_SCALE = 4; + +const WorkspaceCanvas: React.FC = ({ projectId, visibleTiles, subtitles }) => { + const { width: vw, height: vh } = useWindowDimensions(); + const [focusedTileId, setFocusedTileId] = useState('chat'); + + const visibleKey = visibleTiles.join(','); + const bounds = useMemo(() => boundsOf(visibleTiles), [visibleKey]); + const worldW = bounds.w; + const worldH = bounds.h; + const single = visibleTiles.length <= 1; + + // Kamera (shared values fuer 60fps auf dem UI-Thread). + const scale = useSharedValue(1); + const tx = useSharedValue(0); + const ty = useSharedValue(0); + const savedScale = useSharedValue(1); + const savedTx = useSharedValue(0); + const savedTy = useSharedValue(0); + + // Bei nur einer Kachel: immer fokussiert. Verschwindet die fokussierte + // Kachel aus der Sichtbarkeit, auf Chat zurueckfallen. + useEffect(() => { + if (single) { + setFocusedTileId(visibleTiles[0] || 'chat'); + } else if (focusedTileId && !visibleTiles.includes(focusedTileId)) { + setFocusedTileId('chat'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visibleKey]); + + // Kamera auf das Ziel fahren (Fokus-Rect oder Uebersicht). + useEffect(() => { + const cam: Camera = focusedTileId + ? focusCamera(toLocal(TILE_RECTS[focusedTileId], bounds), worldW, worldH, vw, vh) + : overviewCamera(worldW, worldH, vw, vh); + scale.value = withTiming(cam.scale, ANIM); + tx.value = withTiming(cam.tx, ANIM); + ty.value = withTiming(cam.ty, ANIM); + savedScale.value = cam.scale; + savedTx.value = cam.tx; + savedTy.value = cam.ty; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusedTileId, visibleKey, vw, vh]); + + // Hardware-Back: im Fokus (und mehr als eine Kachel) → zurueck zur Uebersicht. + useEffect(() => { + const onBack = () => { + if (focusedTileId && !single) { + setFocusedTileId(null); + return true; + } + return false; + }; + const sub = BackHandler.addEventListener('hardwareBackPress', onBack); + return () => sub.remove(); + }, [focusedTileId, single]); + + // Gesten nur in der Uebersicht (Fokus-Modus: Touches fallen an die Kachel). + const gesturesEnabled = !focusedTileId && !single; + const canvasGesture = useMemo(() => { + const pinch = Gesture.Pinch() + .enabled(gesturesEnabled) + .onUpdate((e) => { + 'worklet'; + scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, savedScale.value * e.scale)); + }) + .onEnd(() => { + 'worklet'; + savedScale.value = scale.value; + }); + const pan = Gesture.Pan() + .enabled(gesturesEnabled) + .minPointers(2) + .onUpdate((e) => { + 'worklet'; + tx.value = savedTx.value + e.translationX; + ty.value = savedTy.value + e.translationY; + }) + .onEnd(() => { + 'worklet'; + savedTx.value = tx.value; + savedTy.value = ty.value; + }); + return Gesture.Simultaneous(pinch, pan); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gesturesEnabled]); + + const worldStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: tx.value }, + { translateY: ty.value }, + { scale: scale.value }, + ], + })); + + const renderContent = (id: TileId) => { + switch (id) { + case 'chat': return ; + case 'editor': return ; + case 'vnc': return ; + case 'preview': return ; + default: return null; + } + }; + + return ( + + {/* Ebene 1 — skalierte Landkarte mit Thumbnails */} + + + + {visibleTiles.map((id) => ( + + ))} + + + + + {/* Ebene 2 — Identity-Content, immer gemountet, nur fokussierte sichtbar */} + + {visibleTiles.map((id) => ( + + {renderContent(id)} + + ))} + + + {/* Steuerung */} + {focusedTileId && !single && ( + setFocusedTileId(null)} activeOpacity={0.8}> + ⤢ Übersicht + + )} + {!focusedTileId && ( + + Kachel antippen zum Öffnen · 2 Finger: zoomen & schieben + + )} + + ); +}; + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#0D0D1A' }, + world: { position: 'absolute', left: 0, top: 0 }, + overviewBtn: { + position: 'absolute', + top: 10, + right: 12, + backgroundColor: 'rgba(18,18,42,0.92)', + borderColor: '#1E1E2E', + borderWidth: 1, + borderRadius: 18, + paddingHorizontal: 14, + paddingVertical: 8, + }, + overviewBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' }, + hintBar: { + position: 'absolute', + bottom: 16, + left: 0, + right: 0, + alignItems: 'center', + }, + hintText: { + color: '#9090B0', + fontSize: 12, + backgroundColor: 'rgba(18,18,42,0.85)', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 14, + overflow: 'hidden', + }, +}); + +export default WorkspaceCanvas; diff --git a/android/src/workspace/WorkspaceScreen.tsx b/android/src/workspace/WorkspaceScreen.tsx new file mode 100644 index 0000000..e03c4b9 --- /dev/null +++ b/android/src/workspace/WorkspaceScreen.tsx @@ -0,0 +1,59 @@ +/** + * WorkspaceScreen — Screen-Wrapper fuer den Workspace-Canvas. + * + * Buendelt die Signale, die entscheiden welche Kacheln sichtbar sind: + * - projectFocus: aktives Projekt + dessen kind ('code'|'chat') + * - codeFile: kam schon eine Code-Datei rein? (Live-Reveal des Editors) + * - desktop: ist ein QEMU-Desktop verfuegbar? (Live-Reveal der VNC-Kachel) + * + * Ersetzt den bisherigen Chat-Tab: die ChatScreen lebt als Kachel im Canvas, + * bleibt aber genau eine Instanz. + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import projectFocus, { FocusSnapshot } from '../services/projectFocus'; +import codeFile from '../services/codeFile'; +import desktop from '../services/desktop'; +import { TileId, visibleTilesFor } from './layout'; +import WorkspaceCanvas from './WorkspaceCanvas'; + +const WorkspaceScreen: React.FC = () => { + const [focus, setFocus] = useState(projectFocus.get()); + const [hasCode, setHasCode] = useState(false); + const [hasDesktop, setHasDesktop] = useState(false); + + useEffect(() => projectFocus.subscribe(setFocus), []); + + const pid = focus.focusedProjectId; + const kind = projectFocus.getProjectKind(pid); + + // Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt? + useEffect(() => { + setHasCode(codeFile.getFiles(pid).length > 0); + return codeFile.subscribe((u) => { + if ((u.projectId || '') === (pid || '')) setHasCode(true); + }); + }, [pid]); + + // Desktop-Signal + einmaliger Check beim Betreten eines Code-Projekts. + useEffect(() => { + setHasDesktop(desktop.getStatus().available); + const unsub = desktop.subscribeStatus((s) => setHasDesktop(s.available)); + if (kind === 'code') desktop.requestCheck(pid); + return unsub; + }, [pid, kind]); + + const visibleTiles: TileId[] = useMemo( + () => visibleTilesFor({ kind, hasCode, hasDesktop }), + [kind, hasCode, hasDesktop], + ); + + const subtitles = useMemo( + () => ({ chat: pid ? projectFocus.getProjectName(pid) : 'Hauptchat' } as Partial>), + [pid, focus.projectNameById], + ); + + return ; +}; + +export default WorkspaceScreen; diff --git a/android/src/workspace/layout.ts b/android/src/workspace/layout.ts new file mode 100644 index 0000000..cf7bf06 --- /dev/null +++ b/android/src/workspace/layout.ts @@ -0,0 +1,95 @@ +/** + * layout — Kachel-Geometrie + Kamera-Mathematik fuer den Workspace-Canvas. + * + * Welt-Koordinaten = Pixel bei Scale 1. Kacheln liegen auf einem festen 2x2- + * Raster. Die "Welt-View" (Animated.View) ist exakt die Bounding-Box der gerade + * sichtbaren Kacheln; Kinder werden relativ zu deren Ursprung positioniert. + * + * RN 0.73 kennt noch kein transformOrigin — Scale dreht um die View-MITTE. + * Alle Kamera-Formeln rechnen deshalb mit Center-Origin: + * screen = center + scale*(p - center) + translate + * wobei center = (worldW/2, worldH/2) (die Welt-View sitzt bei screen 0,0). + */ + +export type TileId = 'chat' | 'editor' | 'vnc' | 'preview'; + +export interface TileRect { x: number; y: number; w: number; h: number; } + +export interface TileDef { id: TileId; title: string; icon: string; } + +// Feste Kachelgroesse im Welt-Raster (Pixel bei Scale 1). +const TILE_W = 1100; +const TILE_H = 1500; +const GAP = 140; + +/** Absolute Welt-Rects pro Kachel (2x2-Raster). */ +export const TILE_RECTS: Record = { + chat: { x: 0, y: 0, w: TILE_W, h: TILE_H }, + editor: { x: TILE_W + GAP, y: 0, w: TILE_W, h: TILE_H }, + vnc: { x: 0, y: TILE_H + GAP, w: TILE_W, h: TILE_H }, + preview: { x: TILE_W + GAP, y: TILE_H + GAP, w: TILE_W, h: TILE_H }, +}; + +export const TILE_META: Record = { + chat: { id: 'chat', title: 'Chat', icon: '💬' }, + editor: { id: 'editor', title: 'Editor', icon: '📝' }, + vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' }, + preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' }, +}; + +// Reihenfolge fuer stabiles Rendering. +export const TILE_ORDER: TileId[] = ['chat', 'editor', 'vnc', 'preview']; + +export interface VisibilitySignals { + kind: 'code' | 'chat'; + hasCode: boolean; // schon ein code_file empfangen + hasDesktop: boolean; // Desktop verfuegbar gemeldet +} + +/** Welche Kacheln sind fuer den aktuellen Kontext sichtbar? + * Chat ist immer da; Code-Projekt (explizit ODER durch ein Live-Signal) + * blendet Editor/Desktop/Vorschau ein. */ +export function visibleTilesFor(sig: VisibilitySignals): TileId[] { + const isCode = sig.kind === 'code' || sig.hasCode || sig.hasDesktop; + if (!isCode) return ['chat']; + return ['chat', 'editor', 'vnc', 'preview']; +} + +/** Bounding-Box mehrerer Kacheln. */ +export function boundsOf(ids: TileId[]): TileRect { + if (ids.length === 0) return { x: 0, y: 0, w: TILE_W, h: TILE_H }; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const id of ids) { + const r = TILE_RECTS[id]; + minX = Math.min(minX, r.x); + minY = Math.min(minY, r.y); + maxX = Math.max(maxX, r.x + r.w); + maxY = Math.max(maxY, r.y + r.h); + } + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; +} + +/** Rect in Welt-View-lokale Koordinaten (relativ zur Bounds-Ecke) umrechnen. */ +export function toLocal(rect: TileRect, bounds: TileRect): TileRect { + return { x: rect.x - bounds.x, y: rect.y - bounds.y, w: rect.w, h: rect.h }; +} + +export interface Camera { scale: number; tx: number; ty: number; } + +/** Kamera, die ein (lokales) Rect bildschirmfuellend zeigt (Fokus-Modus). */ +export function focusCamera(localRect: TileRect, worldW: number, worldH: number, vw: number, vh: number): Camera { + const s = Math.min(vw / localRect.w, vh / localRect.h); + const pcx = localRect.x + localRect.w / 2; + const pcy = localRect.y + localRect.h / 2; + const tx = vw / 2 - worldW / 2 - s * (pcx - worldW / 2); + const ty = vh / 2 - worldH / 2 - s * (pcy - worldH / 2); + return { scale: s, tx, ty }; +} + +/** Kamera, die die gesamte Welt zentriert einpasst (Uebersicht). */ +export function overviewCamera(worldW: number, worldH: number, vw: number, vh: number, pad = 0.86): Camera { + const s = Math.min(vw / worldW, vh / worldH) * pad; + const tx = vw / 2 - worldW / 2; + const ty = vh / 2 - worldH / 2; + return { scale: s, tx, ty }; +} diff --git a/android/src/workspace/tiles/ChatTile.tsx b/android/src/workspace/tiles/ChatTile.tsx new file mode 100644 index 0000000..7990eb9 --- /dev/null +++ b/android/src/workspace/tiles/ChatTile.tsx @@ -0,0 +1,15 @@ +/** + * ChatTile — hostet die bestehende ChatScreen unveraendert als Workspace-Kachel. + * + * ChatScreen bleibt genau EINE Instanz (der Workspace-Tab ersetzt den alten + * Chat-Tab) und wird nie beim Fokuswechsel remountet — sie liegt in der + * Identity-Content-Ebene und wird nur per display ein-/ausgeblendet. So + * behaelt sie RVS-Abos, Audio, Queue-State und Keyboard-Verhalten wie bisher. + */ + +import React from 'react'; +import ChatScreen from '../../screens/ChatScreen'; + +const ChatTile: React.FC = () => ; + +export default React.memo(ChatTile); diff --git a/android/src/workspace/tiles/CodeEditorTile.tsx b/android/src/workspace/tiles/CodeEditorTile.tsx new file mode 100644 index 0000000..92203ab --- /dev/null +++ b/android/src/workspace/tiles/CodeEditorTile.tsx @@ -0,0 +1,30 @@ +/** + * CodeEditorTile — Live-Code-Editor (CodeMirror in einer WebView). + * Platzhalter fuer Commit 4; die CodeMirror-Bridge folgt in Commit 5. + */ + +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +interface Props { + projectId: string; +} + +const CodeEditorTile: React.FC = () => { + return ( + + 📝 + Code-Editor + Wird geladen … + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' }, + icon: { fontSize: 64, marginBottom: 16 }, + text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' }, + sub: { color: '#9090B0', fontSize: 14, marginTop: 8 }, +}); + +export default CodeEditorTile; diff --git a/android/src/workspace/tiles/PreviewTile.tsx b/android/src/workspace/tiles/PreviewTile.tsx new file mode 100644 index 0000000..58cfa65 --- /dev/null +++ b/android/src/workspace/tiles/PreviewTile.tsx @@ -0,0 +1,40 @@ +/** + * PreviewTile — zeigt den letzten Screenshot/Vorschau-Frame eines Code-Projekts + * (z. B. aria-vm screenshot). Fuellt sich, sobald ARIA ein Bild in den + * Vorschau-Kanal legt; bis dahin ein ruhiger Platzhalter. + * + * (Screenshot-Anbindung folgt mit dem QEMU-Track; hier zunaechst die Kachel.) + */ + +import React from 'react'; +import { Image, StyleSheet, Text, View } from 'react-native'; + +interface Props { + imageUri?: string; +} + +const PreviewTile: React.FC = ({ imageUri }) => { + return ( + + {imageUri ? ( + + ) : ( + <> + 🖼️ + Noch keine Vorschau + Screenshots der VM erscheinen hier. + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' }, + image: { width: '100%', height: '100%' }, + icon: { fontSize: 64, marginBottom: 16 }, + text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' }, + sub: { color: '#9090B0', fontSize: 14, marginTop: 8 }, +}); + +export default PreviewTile; diff --git a/android/src/workspace/tiles/VncTile.tsx b/android/src/workspace/tiles/VncTile.tsx new file mode 100644 index 0000000..b588e45 --- /dev/null +++ b/android/src/workspace/tiles/VncTile.tsx @@ -0,0 +1,31 @@ +/** + * VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS). + * Platzhalter fuer Commit 4; der noVNC-Tunnel folgt in Commit 7. + */ + +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +interface Props { + projectId: string; + focused: boolean; +} + +const VncTile: React.FC = () => { + return ( + + 🖥️ + Desktop + Kein Desktop verbunden + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#000000', alignItems: 'center', justifyContent: 'center' }, + icon: { fontSize: 64, marginBottom: 16 }, + text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' }, + sub: { color: '#9090B0', fontSize: 14, marginTop: 8 }, +}); + +export default VncTile;