import React, { useEffect, useRef, useState } from "react"; import { ActivityIndicator, Modal, Pressable, ScrollView, Text, useWindowDimensions, View, type ViewStyle, } from "react-native"; import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; import Animated, { FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import { Check, ChevronDown, GripHorizontal, X } from "lucide-react-native"; import { Button } from "./button"; import { Input } from "./input"; import { usePalette } from "../utils/palette"; export type RunMode = "standalone" | "cloud"; type Endpoint = "entropy" | "custom"; type TestState = "idle" | "testing" | "ok" | "fail"; const SUCCESS = "#16a34a"; export interface ConnectionSettingsProps { visible: boolean; onClose: () => void; /** Current persisted run mode. */ mode: RunMode; /** Current persisted server URL (cloud mode). */ serverUrl: string; /** Persist the chosen mode + resolved URL (empty string in standalone). */ onSave: (mode: RunMode, url: string) => void; /** Default hosted endpoint shown as "Entropy Cloud". */ entropyUrl: string; /** * Health path appended to the resolved server URL. Default `/health`. * (Vault's API base already includes `/api`, so the default resolves to * `/api/health`.) */ healthPath?: string; /** * Validate the parsed JSON health body. Default accepts `{ status: "ok" }` * or `{ status: "healthy" }` — the two Entropy server shapes. */ validateHealth?: (body: unknown) => boolean; /** * When false (default) Standalone is shown but disabled with a "coming * soon" note — vault has no local-only mode yet. Set true once it ships. */ allowStandalone?: boolean; } function normalizeCustom(input: string): string { const v = input.trim().replace(/\/+$/, ""); if (!v) { return ""; } return /^https?:\/\//i.test(v) ? v : `https://${v}`; } const defaultValidate = (body: unknown): boolean => { const s = (body as { status?: string } | null)?.status; return s === "ok" || s === "healthy"; }; /** * Connection dialog — a faithful port of simple-finance's, themed from the * palette (so it tracks the active theme instead of hard-coded brand colors) * and upstreamed here so both apps share one implementation. Draggable card * (grip header, double-tap to recenter), Standalone/Cloud mode pills, a * server dropdown (Entropy-hosted or self-hosted), and a live debounced * health check. Standalone is gated until its backend lands. */ export function ConnectionSettings({ visible, onClose, mode, serverUrl, onSave, entropyUrl, healthPath = "/health", validateHealth = defaultValidate, allowStandalone = false, }: ConnectionSettingsProps) { const { palette: p } = usePalette(); const [localMode, setLocalMode] = useState(mode); const [endpoint, setEndpoint] = useState( serverUrl && serverUrl !== entropyUrl ? "custom" : "entropy" ); const [custom, setCustom] = useState(serverUrl && serverUrl !== entropyUrl ? serverUrl : ""); const [open, setOpen] = useState(false); const [test, setTest] = useState("idle"); const [testMsg, setTestMsg] = useState(""); // Re-sync to props whenever the dialog opens. useEffect(() => { if (!visible) { return; } setLocalMode(mode); setEndpoint(serverUrl && serverUrl !== entropyUrl ? "custom" : "entropy"); setCustom(serverUrl && serverUrl !== entropyUrl ? serverUrl : ""); setOpen(false); setTest("idle"); setTestMsg(""); }, [visible, mode, serverUrl, entropyUrl]); // Dropdown is rendered as the card's last child (so it always paints on // top); we measure the selector to anchor it. const cardRef = useRef(null); const selRef = useRef(null); const [menu, setMenu] = useState<{ top: number; left: number; width: number } | null>(null); const toggleMenu = () => { if (open) { setOpen(false); return; } selRef.current?.measureInWindow((x, y, w, h) => { cardRef.current?.measureInWindow((cx, cy) => { setMenu({ top: y - cy + h + 6, left: x - cx, width: w }); setOpen(true); }); }); }; // ── Draggable card, clamped to stay on screen ────────────────────────────── const win = useWindowDimensions(); const tx = useSharedValue(0); const ty = useSharedValue(0); const startX = useSharedValue(0); const startY = useSharedValue(0); const cardW = useSharedValue(0); const cardH = useSharedValue(0); useEffect(() => { if (visible) { tx.value = 0; ty.value = 0; } }, [visible, tx, ty]); const drag = Gesture.Pan() .onBegin(() => { startX.value = tx.value; startY.value = ty.value; }) .onUpdate((e) => { const maxX = Math.max(0, (win.width - cardW.value) / 2 - 8); const maxY = Math.max(0, (win.height - cardH.value) / 2 - 8); tx.value = Math.min(maxX, Math.max(-maxX, startX.value + e.translationX)); ty.value = Math.min(maxY, Math.max(-maxY, startY.value + e.translationY)); }); const recenter = Gesture.Tap() .numberOfTaps(2) .onEnd(() => { tx.value = withTiming(0, { duration: 220 }); ty.value = withTiming(0, { duration: 220 }); }); const handleGesture = Gesture.Race(drag, recenter); const dragStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }, { translateY: ty.value }], })); const resolvedUrl = endpoint === "entropy" ? entropyUrl : normalizeCustom(custom); const runTest = async (url: string) => { if (!url) { setTest("fail"); setTestMsg("Enter a server address"); return; } setTest("testing"); setTestMsg(""); try { const res = await fetch(`${url}${healthPath}`, { method: "GET" }); const body = (await res.json().catch(() => ({}))) as unknown; if (res.ok && validateHealth(body)) { setTest("ok"); setTestMsg("Connected"); } else { setTest("fail"); setTestMsg(`Responded ${res.status}`); } } catch (e) { setTest("fail"); setTestMsg(e instanceof Error ? e.message : "Could not reach server"); } }; const canSave = localMode === "standalone" || test === "ok"; // Auto-test (debounced) whenever the cloud endpoint changes. useEffect(() => { if (!visible || localMode !== "cloud") { return; } if (!resolvedUrl) { setTest("idle"); return; } const h = setTimeout(() => void runTest(resolvedUrl), 500); return () => clearTimeout(h); // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible, localMode, endpoint, custom, resolvedUrl]); return ( { cardW.value = e.nativeEvent.layout.width; cardH.value = e.nativeEvent.layout.height; }} style={[{ width: "100%", maxWidth: 520 }, dragStyle]} > e.stopPropagation()} style={{ backgroundColor: p.popover, borderRadius: 24, borderColor: p.border, borderWidth: 1, paddingHorizontal: 22, paddingBottom: 22, paddingTop: 10, gap: 16, }} > {/* drag handle: whole header bar drags; double-tap recenters */} Connection {/* mode segmented */} setLocalMode("cloud")} palette={p} /> setLocalMode("standalone")} palette={p} /> {localMode === "standalone" ? ( {allowStandalone ? "Private by default. Your vault stays on this device — no account, no server." : "On-device, account-free mode is coming soon. For now, sign in to your Entropy or self-hosted server."} ) : ( Sign in to sync across devices. Use Entropy's hosted server or point at one you run yourself. {/* endpoint selector — dropdown floats as an overlay so the dialog never resizes when it opens. */} Server {endpoint === "entropy" ? "Entropy Cloud" : "Custom server"} {endpoint === "entropy" ? entropyUrl : "DNS / IP / host:port"} {endpoint === "custom" ? ( ) : null} {/* live connection status — fixed height, never shifts layout */} void runTest(resolvedUrl)} style={{ height: 22, flexDirection: "row", alignItems: "center", gap: 7 }} accessibilityRole="button" accessibilityLabel="Test connection" > {test === "testing" ? ( <> Checking connection… ) : test === "ok" ? ( <> {testMsg} ) : test === "fail" ? ( <> {testMsg} · tap to retry ) : ( <> Not checked )} )} {/* Server dropdown — rendered last so it paints above everything, anchored under the selector via measurement. */} {open && menu ? ( <> setOpen(false)} style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }} /> ) : null} ); } type Pal = ReturnType["palette"]; function Dot({ color }: { color: string }) { return ; } function ModePill({ label, active, disabled, onPress, palette: p, }: { label: string; active: boolean; disabled?: boolean; onPress: () => void; palette: Pal; }) { return ( !disabled && onPress()} disabled={disabled} style={{ flex: 1, paddingVertical: 9, borderRadius: 10, alignItems: "center", backgroundColor: active ? p.primary : "transparent", opacity: disabled ? 0.45 : 1, }} > {label} ); } function Option({ label, sub, active, onPress, palette: p, }: { label: string; sub: string; active: boolean; onPress: () => void; palette: Pal; }) { return ( {label} {sub} {active ? : null} ); } export default ConnectionSettings;