import React, { useState, useRef, useCallback, useEffect } from "react"; import { View, Text, TextInput, Pressable, ScrollView, Animated, Easing, Platform, Keyboard, StyleSheet, } from "react-native"; import { fetch } from "expo/fetch"; import Constants from "expo-constants"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Clipboard from "expo-clipboard"; import { useUIStream } from "@json-render/react-native"; import { AppRenderer } from "../lib/render/renderer"; // Resolve the Metro dev server origin for API route calls. // expo/fetch doesn't resolve relative URLs like the built-in RN fetch does. function getApiBaseUrl(): string { const debuggerHost = Constants.expoConfig?.hostUri ?? Constants.manifest2?.extra?.expoGo?.debuggerHost; if (debuggerHost) { const host = debuggerHost.split(":").shift(); // Metro dev server runs on port 8081 by default return `http://${host}:8081`; } return "http://localhost:8081"; } const API_BASE = getApiBaseUrl(); export default function HomeScreen() { const insets = useSafeAreaInsets(); const [prompt, setPrompt] = useState(""); const { spec, isStreaming, error, rawLines, send, stop, clear } = useUIStream( { api: `${API_BASE}/api/generate`, fetch, validate: true, onError: (err) => console.error("Generation error:", err), }, ); const handleGenerate = async () => { if (!prompt.trim() || isStreaming) return; const text = prompt.trim(); setPrompt(""); Keyboard.dismiss(); await send(text, { previousSpec: spec ?? undefined, }); }; type ViewMode = "ui" | "json" | "jsonl"; const [viewMode, setViewMode] = useState("ui"); const [showMenu, setShowMenu] = useState(false); const [confirmingClear, setConfirmingClear] = useState(false); const [copied, setCopied] = useState(false); const handleCopy = useCallback(async () => { const text = viewMode === "jsonl" ? rawLines.join("\n") : JSON.stringify(spec, null, 2); if (!text) return; await Clipboard.setStringAsync(text); setCopied(true); setTimeout(() => setCopied(false), 2000); }, [spec, rawLines, viewMode]); const handleClear = useCallback(() => { if (!confirmingClear) { setConfirmingClear(true); return; } setConfirmingClear(false); setShowMenu(false); clear(); setPrompt(""); setViewMode("ui"); }, [confirmingClear, clear]); const hasContent = !!spec || isStreaming || !!error; const PROMPT_BAR_HEIGHT = 80; // Animate the floating prompt bar in sync with the keyboard const keyboardOffset = useRef(new Animated.Value(0)).current; useEffect(() => { const showEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow"; const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide"; const showSub = Keyboard.addListener(showEvent, (e) => { Animated.timing(keyboardOffset, { toValue: -Math.max(0, e.endCoordinates.height - insets.bottom - 4), duration: Platform.OS === "ios" ? e.duration : 200, easing: Easing.bezier(0.17, 0.59, 0.4, 0.99), useNativeDriver: true, }).start(); }); const hideSub = Keyboard.addListener(hideEvent, (e) => { Animated.timing(keyboardOffset, { toValue: 0, duration: Platform.OS === "ios" ? (e.duration ?? 250) : 200, easing: Easing.bezier(0.17, 0.59, 0.4, 0.99), useNativeDriver: true, }).start(); }); return () => { showSub.remove(); hideSub.remove(); }; }, [insets.bottom, keyboardOffset]); return ( {hasContent ? ( viewMode !== "ui" ? ( {viewMode === "json" ? "JSON Spec" : "JSONL Stream"} {copied ? "Copied" : "Copy"} {viewMode === "json" ? JSON.stringify(spec, null, 2) : rawLines.join("\n")} ) : ( {error && ( Generation failed {error.message} )} {(spec || isStreaming) && ( )} ) ) : ( json-render Describe a UI and watch it appear Try something like "a settings page with a dark mode toggle, notification preferences, and a profile card with an avatar" )} {/* Menu popover */} {showMenu && ( { setShowMenu(false); setConfirmingClear(false); }} > { setViewMode((v) => (v === "json" ? "ui" : "json")); setShowMenu(false); }} > {viewMode === "json" ? "Hide JSON" : "Show JSON"} { setViewMode((v) => (v === "jsonl" ? "ui" : "jsonl")); setShowMenu(false); }} > {viewMode === "jsonl" ? "Hide JSONL" : "Show JSONL"} {confirmingClear ? "Are you sure?" : "Clear"} )} {/* Prompt bar */} {hasContent && spec && !isStreaming && ( { setShowMenu((v) => !v); setConfirmingClear(false); }} > ยทยทยท )} {isStreaming ? ( ) : ( Go )} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#ffffff", }, flex: { flex: 1, }, splash: { flex: 1, justifyContent: "center", }, splashContent: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 40, }, splashTitle: { fontSize: 32, fontWeight: "700", color: "#111827", marginBottom: 8, }, splashSubtitle: { fontSize: 16, color: "#6b7280", marginBottom: 24, }, splashHint: { fontSize: 14, color: "#9ca3af", textAlign: "center", lineHeight: 20, }, contentContainer: {}, jsonContainer: { margin: 16, padding: 16, backgroundColor: "#f9fafb", borderRadius: 12, borderWidth: 1, borderColor: "#e5e7eb", }, jsonHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 12, }, jsonHeaderTitle: { fontSize: 13, fontWeight: "600", color: "#9ca3af", textTransform: "uppercase", letterSpacing: 0.5, }, copyButton: { backgroundColor: "#e5e7eb", borderRadius: 8, paddingHorizontal: 12, paddingVertical: 6, }, copyButtonText: { fontSize: 13, fontWeight: "600", color: "#374151", }, jsonText: { fontSize: 12, fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", color: "#374151", lineHeight: 18, }, errorContainer: { backgroundColor: "#fef2f2", borderRadius: 12, padding: 16, margin: 16, borderWidth: 1, borderColor: "#fecaca", }, errorTitle: { fontSize: 16, fontWeight: "600", color: "#dc2626", marginBottom: 4, }, errorText: { fontSize: 14, color: "#991b1b", }, promptArea: { position: "absolute", left: 12, right: 12, }, menuButton: { borderRadius: 20, width: 38, height: 38, alignItems: "center", justifyContent: "center", alignSelf: "center", }, menuButtonText: { fontSize: 18, color: "#9ca3af", fontWeight: "700", }, menuOverlay: { ...StyleSheet.absoluteFillObject, zIndex: 10, }, menuPopover: { position: "absolute", right: 12, backgroundColor: "#ffffff", borderRadius: 14, borderWidth: 1, borderColor: "#e5e7eb", shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.15, shadowRadius: 12, elevation: 10, minWidth: 160, overflow: "hidden", }, menuItem: { paddingHorizontal: 16, paddingVertical: 12, }, menuItemText: { fontSize: 15, color: "#111827", fontWeight: "500", }, menuItemDestructive: { color: "#ef4444", }, menuItemDestructiveConfirm: { fontWeight: "700", }, menuDivider: { height: StyleSheet.hairlineWidth, backgroundColor: "#e5e7eb", }, promptBar: { flexDirection: "row", alignItems: "flex-end", backgroundColor: "#ffffff", borderRadius: 24, borderWidth: 1, borderColor: "#e5e7eb", paddingLeft: 18, paddingRight: 4, paddingVertical: 4, shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.12, shadowRadius: 16, elevation: 8, }, promptInput: { flex: 1, fontSize: 16, color: "#111827", paddingVertical: 10, maxHeight: 100, }, sendButton: { backgroundColor: "#3b82f6", borderRadius: 20, paddingHorizontal: 18, height: 38, alignItems: "center", justifyContent: "center", }, sendButtonDisabled: { opacity: 0.4, }, sendButtonText: { color: "#ffffff", fontSize: 15, fontWeight: "600", }, stopButton: { backgroundColor: "#ef4444", borderRadius: 20, width: 38, height: 38, alignItems: "center", justifyContent: "center", }, stopIcon: { width: 14, height: 14, backgroundColor: "#ffffff", borderRadius: 2, }, });