diff --git a/android/src/workspace/AriaViewCanvas.tsx b/android/src/workspace/AriaViewCanvas.tsx new file mode 100644 index 0000000..737f8ff --- /dev/null +++ b/android/src/workspace/AriaViewCanvas.tsx @@ -0,0 +1,175 @@ +/** + * AriaViewCanvas — die pannbare Flaeche, auf der ARIAs komponierte Ansicht + * (aria_view) MATERIALISIERT: Orb oben, darunter die Karten. Erscheint als + * Overlay ueber dem Chat, sobald ARIA present_view aufruft ("sag was → Orb denkt + * → Karte fliegt rein"). Der erste, greifbare Vorgeschmack aufs generative + * Cockpit (M1). + * + * Bedienung (NoMachine-Prinzip): 2-Finger halten + schieben bewegt die Welt, + * Pinch zoomt. Ein-Finger-Touch geht an die Karten durch (Scrollen). Die Welt + * traegt gerenderte/gestreamte Inhalte — interaktive native Panels rasten + * spaeter bei Scale 1 ein (Chat bleibt separat darunter). + * + * Geraete-agnostisch gehalten: liest nur die ViewSpec, damit ein spaeterer Web-/ + * AR-Renderer dieselbe Spec konsumieren kann. + */ + +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import Animated, { + FadeInDown, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { ViewSpec } from '../services/ariaView'; +import Orb from './Orb'; +import CardView from './CardView'; + +const MIN_SCALE = 0.5; +const MAX_SCALE = 3; + +interface Props { + view: ViewSpec; + onClose: () => void; +} + +const AriaViewCanvas: React.FC = ({ view, onClose }) => { + const tx = useSharedValue(0); + const ty = useSharedValue(0); + const scale = useSharedValue(1); + const savedTx = useSharedValue(0); + const savedTy = useSharedValue(0); + const savedScale = useSharedValue(1); + + const pan = Gesture.Pan() + .minPointers(2) + .maxPointers(2) + .onUpdate((e) => { + tx.value = savedTx.value + e.translationX; + ty.value = savedTy.value + e.translationY; + }) + .onEnd(() => { + savedTx.value = tx.value; + savedTy.value = ty.value; + }); + + const pinch = Gesture.Pinch() + .onUpdate((e) => { + const next = savedScale.value * e.scale; + scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, next)); + }) + .onEnd(() => { + savedScale.value = scale.value; + }); + + const composed = Gesture.Simultaneous(pan, pinch); + + const worldStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: tx.value }, + { translateY: ty.value }, + { scale: scale.value }, + ], + })); + + const resetCamera = () => { + tx.value = withTiming(0); + ty.value = withTiming(0); + scale.value = withTiming(1); + savedTx.value = 0; + savedTy.value = 0; + savedScale.value = 1; + }; + + const cards = Array.isArray(view.cards) ? view.cards : []; + + return ( + + + + + + + {!!view.title && {view.title}} + + {cards.map((c, i) => ( + + + + ))} + + + + + {/* Steuerung — ausserhalb des Transforms, immer bei Scale 1 bedienbar */} + + + + + + + + + + 2 Finger: schieben · Pinch: zoomen + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(6,6,16,0.94)', + zIndex: 50, + }, + world: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + paddingTop: 48, + paddingHorizontal: 18, + }, + orbWrap: { marginTop: 8, marginBottom: 6 }, + worldTitle: { + color: '#C9C9FF', + fontSize: 18, + fontWeight: '700', + marginBottom: 4, + textAlign: 'center', + }, + cards: { width: '100%', maxWidth: 560 }, + topBar: { + position: 'absolute', + top: 10, + right: 12, + flexDirection: 'row', + }, + iconBtn: { + width: 40, + height: 40, + borderRadius: 20, + marginLeft: 10, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(30,30,60,0.9)', + borderWidth: 1, + borderColor: 'rgba(123,92,255,0.4)', + }, + icon: { color: '#C9C9FF', fontSize: 18 }, + hintWrap: { + position: 'absolute', + bottom: 14, + alignSelf: 'center', + }, + hint: { + color: '#6A6A90', + fontSize: 12, + }, +}); + +export default AriaViewCanvas; diff --git a/android/src/workspace/CardView.tsx b/android/src/workspace/CardView.tsx new file mode 100644 index 0000000..35176c7 --- /dev/null +++ b/android/src/workspace/CardView.tsx @@ -0,0 +1,114 @@ +/** + * CardView — rendert EINE Karte einer aria_view-Spec (M1). Schaltet nach + * card.type auf den passenden Renderer. Unbekannte Typen werden als Text- + * Fallback gezeigt (nie crashen). + * + * Bewusst dependency-leicht (v1): Markdown wird als Klartext dargestellt, Map + * als Marker-Liste (kein Karten-Lib), Code als Monospace-Block. Spaeter koennen + * einzelne Renderer aufgebohrt werden, ohne die Spec/den Fluss zu aendern. + */ + +import React from 'react'; +import { Image, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { ViewCard, ViewMarker } from '../services/ariaView'; + +const ImageBody: React.FC<{ src?: string }> = ({ src }) => { + const isUrl = !!src && /^https?:\/\//i.test(src); + if (isUrl) { + return ; + } + return 🖼️ {src || '(kein Bild)'}; +}; + +const ListBody: React.FC<{ md?: string }> = ({ md }) => { + const lines = (md || '') + .split('\n') + .map((l) => l.replace(/^\s*[-*•]\s?/, '').trim()) + .filter(Boolean); + if (lines.length === 0) return (leer); + return ( + + {lines.map((l, i) => ( + + + {l} + + ))} + + ); +}; + +const MapBody: React.FC<{ markers?: ViewMarker[] }> = ({ markers }) => { + const ms = Array.isArray(markers) ? markers : []; + return ( + + 🗺️ Karte ({ms.length} Orte) + {ms.map((m, i) => ( + + 📍 {m.label || `${m.lat?.toFixed?.(4)}, ${m.lon?.toFixed?.(4)}`} + + ))} + + ); +}; + +const CodeBody: React.FC<{ md?: string; path?: string; lang?: string }> = ({ md, path, lang }) => ( + + {(path || lang) && ( + + {path || ''}{lang ? ` · ${lang}` : ''} + + )} + + {md || ''} + + +); + +const CardView: React.FC<{ card: ViewCard }> = ({ card }) => { + return ( + + {!!card.title && {card.title}} + {card.type === 'image' ? ( + + ) : card.type === 'list' ? ( + + ) : card.type === 'map' ? ( + + ) : card.type === 'code' ? ( + + ) : ( + {card.md || ''} + )} + + ); +}; + +const styles = StyleSheet.create({ + card: { + backgroundColor: 'rgba(18,18,42,0.92)', + borderColor: 'rgba(123,92,255,0.35)', + borderWidth: 1, + borderRadius: 14, + padding: 14, + marginVertical: 8, + shadowColor: '#7B5CFF', + shadowOpacity: 0.25, + shadowRadius: 12, + shadowOffset: { width: 0, height: 2 }, + elevation: 6, + }, + cardTitle: { color: '#C9C9FF', fontSize: 15, fontWeight: '700', marginBottom: 8 }, + text: { color: '#E6E6F0', fontSize: 14, lineHeight: 20, flexShrink: 1 }, + muted: { color: '#8A8AB0', fontSize: 13, fontStyle: 'italic' }, + image: { width: '100%', height: 200, borderRadius: 8, backgroundColor: '#0D0D1A' }, + listRow: { flexDirection: 'row', alignItems: 'flex-start', marginVertical: 2 }, + bullet: { color: '#7B5CFF', marginRight: 8, fontSize: 14, lineHeight: 20 }, + map: { backgroundColor: '#0D0D1A', borderRadius: 8, padding: 10 }, + mapHint: { color: '#00B4D8', fontSize: 13, fontWeight: '600', marginBottom: 6 }, + codeCaption: { color: '#8A8AB0', fontSize: 12, marginBottom: 6 }, + codeScroll: { backgroundColor: '#0A0A14', borderRadius: 8, padding: 10 }, + code: { color: '#B9F5C9', fontFamily: 'monospace', fontSize: 12.5, lineHeight: 18 }, +}); + +export default React.memo(CardView); diff --git a/android/src/workspace/Orb.tsx b/android/src/workspace/Orb.tsx new file mode 100644 index 0000000..afd89ca --- /dev/null +++ b/android/src/workspace/Orb.tsx @@ -0,0 +1,106 @@ +/** + * Orb — ARIAs Praesenz-Avatar (M1). Zeigt ihren Zustand (idle/listening/ + * thinking/speaking/working) als pulsierender Leucht-Kern und ist das + * verbindende Element ueber alle Oberflaechen (App/Web/spaeter Brille). + * + * Reine Optik, keine Logik — der Zustand kommt von aussen (aria_view.orb bzw. + * spaeter direkt von Audio/Wake-Word-Signalen). Dependency-leicht: nur + * reanimated (schon installiert), kein SVG/Gradient noetig. + */ + +import React, { useEffect } from 'react'; +import { StyleSheet, View } from 'react-native'; +import Animated, { + Easing, + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; +import { OrbState } from '../services/ariaView'; + +const COLORS: Record = { + idle: '#3A6EA5', + listening: '#00B4D8', + thinking: '#7B5CFF', + speaking: '#34C759', + working: '#FF9500', +}; + +interface Props { + state?: OrbState; + size?: number; +} + +const Orb: React.FC = ({ state = 'idle', size = 120 }) => { + const pulse = useSharedValue(1); + + useEffect(() => { + const fast = state === 'thinking' || state === 'working'; + cancelAnimation(pulse); + pulse.value = 1; + pulse.value = withRepeat( + withTiming(fast ? 1.14 : 1.07, { + duration: fast ? 620 : 1500, + easing: Easing.inOut(Easing.ease), + }), + -1, + true, + ); + return () => cancelAnimation(pulse); + }, [state, pulse]); + + const animStyle = useAnimatedStyle(() => ({ transform: [{ scale: pulse.value }] })); + const color = COLORS[state] || COLORS.idle; + + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + wrap: { alignItems: 'center', justifyContent: 'center' }, + glow: { position: 'absolute', opacity: 0.22 }, + ring: { position: 'absolute', borderWidth: 2, opacity: 0.55 }, + core: { + shadowOpacity: 0.9, + shadowRadius: 16, + shadowOffset: { width: 0, height: 0 }, + elevation: 12, + }, +}); + +export default React.memo(Orb); diff --git a/android/src/workspace/WorkspaceScreen.tsx b/android/src/workspace/WorkspaceScreen.tsx index 0c0225b..896b526 100644 --- a/android/src/workspace/WorkspaceScreen.tsx +++ b/android/src/workspace/WorkspaceScreen.tsx @@ -9,6 +9,7 @@ */ import React, { useEffect, useMemo, useState } from 'react'; +import { View } from 'react-native'; import projectFocus, { FocusSnapshot } from '../services/projectFocus'; import codeFile from '../services/codeFile'; import brainApi from '../services/brainApi'; @@ -16,6 +17,8 @@ import viewMode, { ViewModeValue } from '../services/viewMode'; import ChatScreen from '../screens/ChatScreen'; import { TileId } from './layout'; import WorkspaceDeck from './WorkspaceDeck'; +import ariaView, { AriaView } from '../services/ariaView'; +import AriaViewCanvas from './AriaViewCanvas'; const COCKPIT_PANELS: TileId[] = ['chat', 'files', 'editor', 'vnc']; @@ -24,12 +27,21 @@ const WorkspaceScreen: React.FC = () => { const [focus, setFocus] = useState(projectFocus.get()); const [hasCode, setHasCode] = useState(false); const [hasDesktop, setHasDesktop] = useState(false); + const [view, setView] = useState(undefined); useEffect(() => viewMode.subscribe(setMode), []); useEffect(() => projectFocus.subscribe(setFocus), []); const pid = focus.focusedProjectId; + // aria_view: ARIAs komponierte Ansicht fuers fokussierte Projekt spiegeln. + useEffect(() => { + setView(ariaView.getView(pid)); + return ariaView.subscribe((v) => { + if ((v.projectId || '') === (pid || '')) setView(v); + }); + }, [pid]); + // Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt? useEffect(() => { setHasCode(codeFile.getFiles(pid).length > 0); @@ -59,13 +71,32 @@ const WorkspaceScreen: React.FC = () => { vnc: hasDesktop ? '#34C759' : undefined, } as Partial>), [hasCode, hasDesktop]); - // Kompakt-Ansicht: klassischer Vollbild-Chat, exakt wie vor dem Umbau. - if (mode === 'compact') { - return ; - } + // Kompakt-Ansicht: klassischer Vollbild-Chat; Cockpit: Workbench mit Dock. + const content = + mode === 'compact' ? ( + + ) : ( + + ); - // Cockpit: Workbench mit Dock. - return ; + // Generative Flaeche als Overlay, sobald ARIA fuer dieses Projekt eine Ansicht + // komponiert hat (present_view → aria_view). Chat/Cockpit bleiben darunter. + const showView = !!view && (view.projectId || '') === (pid || ''); + + return ( + + {content} + {showView && view && ( + { + ariaView.clear(pid); + setView(undefined); + }} + /> + )} + + ); }; export default WorkspaceScreen;