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;