Add welcome onboarding and remove default localhost host

This commit is contained in:
Mohamed Boudra
2026-01-24 17:01:12 +07:00
parent f8f37ace58
commit 9b82c02fd2
17 changed files with 1222 additions and 250 deletions
@@ -0,0 +1,224 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
View,
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
BottomSheetModal,
BottomSheetBackdrop,
BottomSheetScrollView,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native";
const styles = StyleSheet.create((theme) => ({
desktopOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.55)",
justifyContent: "center",
alignItems: "center",
padding: theme.spacing[6],
},
desktopCard: {
width: "100%",
maxWidth: 520,
maxHeight: "85%",
backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: theme.colors.surface2,
overflow: "hidden",
},
header: {
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
closeButton: {
padding: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
},
desktopScroll: {
flex: 1,
},
desktopContent: {
padding: theme.spacing[6],
gap: theme.spacing[4],
},
bottomSheetHandle: {
backgroundColor: theme.colors.surface2,
},
bottomSheetHeader: {
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[3],
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2,
},
bottomSheetContent: {
padding: theme.spacing[6],
gap: theme.spacing[4],
},
}));
function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
return (
<View
style={[
style,
{
backgroundColor: theme.colors.surface1,
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
},
]}
/>
);
}
export interface AdaptiveModalSheetProps {
title: string;
visible: boolean;
onClose: () => void;
children: ReactNode;
snapPoints?: string[];
testID?: string;
}
export function AdaptiveModalSheet({
title,
visible,
onClose,
children,
snapPoints,
testID,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sheetRef = useRef<BottomSheetModal>(null);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
useEffect(() => {
if (!isMobile) return;
if (visible) {
sheetRef.current?.present();
} else {
sheetRef.current?.dismiss();
}
}, [visible, isMobile]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
onClose();
}
},
[onClose]
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
),
[]
);
if (isMobile) {
return (
<BottomSheetModal
ref={sheetRef}
snapPoints={resolvedSnapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={SheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.title}>{title}</Text>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
</BottomSheetModal>
);
}
return (
<Modal
transparent
animationType="fade"
visible={visible}
onRequestClose={onClose}
hardwareAccelerated
>
<View style={styles.desktopOverlay} testID={testID}>
<Pressable
accessibilityLabel="Dismiss"
style={{ ...StyleSheet.absoluteFillObject }}
onPress={onClose}
/>
<View style={styles.desktopCard}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</View>
</View>
</Modal>
);
}
@@ -0,0 +1,94 @@
import { useCallback } from "react";
import { Pressable, Text, View, Platform } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
const styles = StyleSheet.create((theme) => ({
option: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[4],
padding: theme.spacing[4],
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
},
optionText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
optionSubtext: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
optionBody: {
flex: 1,
},
}));
export interface AddHostMethodModalProps {
visible: boolean;
onClose: () => void;
onDirectConnection: () => void;
onScanQr: () => void;
onPasteLink: () => void;
}
export function AddHostMethodModal({
visible,
onClose,
onDirectConnection,
onScanQr,
onPasteLink,
}: AddHostMethodModalProps) {
const { theme } = useUnistyles();
const handleDirect = useCallback(() => {
onClose();
onDirectConnection();
}, [onClose, onDirectConnection]);
const handleScan = useCallback(() => {
onClose();
onScanQr();
}, [onClose, onScanQr]);
const handlePaste = useCallback(() => {
onClose();
onPasteLink();
}, [onClose, onPasteLink]);
return (
<AdaptiveModalSheet title="Add host" visible={visible} onClose={onClose} testID="add-host-method-modal">
<Pressable style={styles.option} onPress={handleDirect} accessibilityLabel="Direct connection">
<Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Direct connection</Text>
<Text style={styles.optionSubtext}>Enter a host and port.</Text>
</View>
</Pressable>
{Platform.OS !== "web" ? (
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
<QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Scan QR code</Text>
<Text style={styles.optionSubtext}>Use your camera to pair.</Text>
</View>
</Pressable>
) : null}
<Pressable style={styles.option} onPress={handlePaste} accessibilityLabel="Paste pairing link">
<ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Paste pairing link</Text>
<Text style={styles.optionSubtext}>Works without a camera.</Text>
</View>
</Pressable>
</AdaptiveModalSheet>
);
}
@@ -0,0 +1,288 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Pressable, Text, TextInput, View } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { Link2 } from "lucide-react-native";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
import { normalizeHostPort } from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError, testDaemonEndpointConnection } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
const styles = StyleSheet.create((theme) => ({
field: {
gap: theme.spacing[2],
},
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
input: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
borderWidth: 1,
borderColor: theme.colors.border,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
marginTop: theme.spacing[2],
},
button: {
flex: 1,
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
primaryButton: {
backgroundColor: theme.colors.palette.blue[500],
},
buttonText: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
primaryButtonText: {
color: theme.colors.palette.white,
},
helper: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
error: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
connectRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
},
}));
function isHostPortOnly(raw: string): boolean {
return !raw.includes("://") && !raw.includes("/");
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
if (!trimmed) return null;
return trimmed;
}
function formatTechnicalTransportDetails(details: Array<string | null>): string | null {
const unique = Array.from(
new Set(
details
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0)
)
);
if (unique.length === 0) return null;
const allGeneric = unique.every((value) => {
const lower = value.toLowerCase();
return lower === "transport error" || lower === "transport closed";
});
if (allGeneric) {
return `${unique[0]} (no additional details provided)`;
}
return unique.join(" — ");
}
function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: string; detail: string | null; raw: string | null } {
const title = `We failed to connect to ${endpoint}.`;
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError]) ??
normalizeTransportMessage(error.message)
);
}
if (error instanceof Error) {
return normalizeTransportMessage(error.message);
}
return null;
})();
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (rawLower.includes("timed out")) {
detail = "Connection timed out. Check the host/port and your network.";
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = "Connection was refused. Is the daemon running on that host and port?";
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = "Host not found. Check the hostname and try again.";
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = "Host is unreachable. Check your network and firewall.";
} else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) {
detail = "TLS/certificate error. This app expects a daemon reachable over the local network or via relay.";
} else if (raw) {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
} else {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
}
return { title, detail, raw };
}
export interface AddHostModalProps {
visible: boolean;
onClose: () => void;
onSaved?: (profile: HostProfile) => void;
}
export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const { addDaemon } = useDaemonRegistry();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
const labelInputRef = useRef<TextInput>(null);
const hostInputRef = useRef<TextInput>(null);
const [label, setLabel] = useState("");
const [endpointRaw, setEndpointRaw] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const handleClose = useCallback(() => {
if (isSaving) return;
setLabel("");
setEndpointRaw("");
setErrorMessage("");
onClose();
}, [isSaving, onClose]);
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = endpointRaw.trim();
if (!raw) {
setErrorMessage("Host is required");
return;
}
if (!isHostPortOnly(raw)) {
setErrorMessage("Enter host:port only (no ws://, no /ws)");
return;
}
let endpoint: string;
try {
endpoint = normalizeHostPort(raw);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid host:port";
setErrorMessage(message);
return;
}
try {
setIsSaving(true);
setErrorMessage("");
await testDaemonEndpointConnection(endpoint);
const profile = await addDaemon({
label: label.trim(),
endpoints: [endpoint],
});
onSaved?.(profile);
handleClose();
} catch (error) {
const { title, detail, raw } = buildConnectionFailureCopy(endpoint, error);
const combined =
raw && detail && raw !== detail
? `${title}\n${detail}\nDetails: ${raw}`
: detail
? `${title}\n${detail}`
: title;
setErrorMessage(combined);
if (!isMobile) {
// Desktop/web: also surface it as a dialog for quick visibility.
Alert.alert("Connection failed", combined);
}
} finally {
setIsSaving(false);
}
}, [addDaemon, endpointRaw, handleClose, isMobile, isSaving, label, onSaved]);
return (
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
<Text style={styles.helper}>Connect to a daemon by entering host:port.</Text>
<View style={styles.field}>
<Text style={styles.label}>Label (optional)</Text>
<InputComponent
ref={labelInputRef as any}
value={label}
onChangeText={setLabel}
placeholder="My Host"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
editable={!isSaving}
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => hostInputRef.current?.focus()}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Host</Text>
<InputComponent
ref={hostInputRef as any}
value={endpointRaw}
onChangeText={setEndpointRaw}
placeholder="host:6767"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={() => void handleSave()}
/>
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
<View style={styles.actions}>
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving}>
<Text style={styles.buttonText}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.button, styles.primaryButton, isSaving ? { opacity: 0.7 } : null]}
onPress={() => void handleSave()}
disabled={isSaving}
>
<View style={styles.connectRow}>
<Link2 size={16} color={theme.colors.palette.white} />
<Text style={[styles.buttonText, styles.primaryButtonText]}>
{isSaving ? "Connecting..." : "Connect & Save"}
</Text>
</View>
</Pressable>
</View>
</AdaptiveModalSheet>
);
}
@@ -58,12 +58,15 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const candidates = useMemo(() => buildCandidateUrls(daemon), [daemon]);
const [activeIndex, setActiveIndex] = useState(0);
const activeUrl = candidates[activeIndex] ?? candidates[0] ?? buildDaemonWebSocketUrl("localhost:6767");
const activeUrl = candidates[activeIndex] ?? candidates[0] ?? null;
const lastAttemptedUrlRef = useRef<string | null>(null);
const pendingMetadataWriteRef = useRef(false);
useEffect(() => {
if (!activeUrl) {
return;
}
// If the active URL fell out of the candidate set (e.g. endpoints updated), snap back.
const idx = candidates.indexOf(activeUrl);
if (idx === -1) {
@@ -72,6 +75,10 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [candidates.join("|")]);
if (!activeUrl) {
return null;
}
const connection = connectionStates.get(daemon.id);
const status = connection?.status ?? "idle";
const lastError = connection?.lastError ?? null;
@@ -0,0 +1,157 @@
import { useCallback, useMemo, useState } from "react";
import { Alert, Pressable, Text, TextInput, View } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { Link } from "lucide-react-native";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
const styles = StyleSheet.create((theme) => ({
helper: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
field: {
gap: theme.spacing[2],
},
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
input: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
borderWidth: 1,
borderColor: theme.colors.border,
},
error: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
marginTop: theme.spacing[2],
},
button: {
flex: 1,
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
primaryButton: {
backgroundColor: theme.colors.palette.blue[500],
},
buttonText: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
primaryButtonText: {
color: theme.colors.palette.white,
},
connectRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
},
}));
export interface PairLinkModalProps {
visible: boolean;
onClose: () => void;
onSaved?: (profile: HostProfile) => void;
}
export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps) {
const { theme } = useUnistyles();
const { upsertDaemonFromOfferUrl } = useDaemonRegistry();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const InputComponent = useMemo(() => (isMobile ? BottomSheetTextInput : TextInput), [isMobile]);
const [offerUrl, setOfferUrl] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const handleClose = useCallback(() => {
if (isSaving) return;
setOfferUrl("");
setErrorMessage("");
onClose();
}, [isSaving, onClose]);
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = offerUrl.trim();
if (!raw) {
setErrorMessage("Paste a pairing link (…/#offer=...)");
return;
}
if (!raw.includes("#offer=")) {
setErrorMessage("Link must include #offer=...");
return;
}
try {
setIsSaving(true);
setErrorMessage("");
const profile = await upsertDaemonFromOfferUrl(raw);
onSaved?.(profile);
handleClose();
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to pair host";
setErrorMessage(message);
if (!isMobile) {
Alert.alert("Pairing failed", message);
}
} finally {
setIsSaving(false);
}
}, [handleClose, isMobile, isSaving, offerUrl, onSaved, upsertDaemonFromOfferUrl]);
return (
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">
<Text style={styles.helper}>Paste the daemon’s pairing link.</Text>
<View style={styles.field}>
<Text style={styles.label}>Pairing link</Text>
<InputComponent
value={offerUrl}
onChangeText={setOfferUrl}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
<View style={styles.actions}>
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving}>
<Text style={styles.buttonText}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.button, styles.primaryButton, isSaving ? { opacity: 0.7 } : null]}
onPress={() => void handleSave()}
disabled={isSaving}
>
<View style={styles.connectRow}>
<Link size={16} color={theme.colors.palette.white} />
<Text style={[styles.buttonText, styles.primaryButtonText]}>
{isSaving ? "Pairing..." : "Pair"}
</Text>
</View>
</Pressable>
</View>
</AdaptiveModalSheet>
);
}
@@ -0,0 +1,114 @@
import { useState } from "react";
import { Image, Pressable, Text, View, Platform } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/contexts/daemon-registry-context";
import { AddHostModal } from "./add-host-modal";
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
padding: theme.spacing[8],
justifyContent: "center",
alignItems: "center",
},
logo: {
width: 96,
height: 96,
marginBottom: theme.spacing[6],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.bold,
marginBottom: theme.spacing[3],
textAlign: "center",
},
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
marginBottom: theme.spacing[8],
},
actions: {
width: "100%",
maxWidth: 420,
gap: theme.spacing[3],
},
actionButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[4],
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
},
actionButtonPrimary: {
backgroundColor: theme.colors.palette.blue[500],
borderColor: theme.colors.palette.blue[500],
},
actionText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
actionTextPrimary: {
color: theme.colors.palette.white,
},
}));
export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void;
}
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles();
const router = useRouter();
const [isDirectOpen, setIsDirectOpen] = useState(false);
return (
<View style={styles.container} testID="welcome-screen">
<Image
source={require("../../assets/images/icon.png")}
style={styles.logo}
resizeMode="contain"
/>
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Add a host to start.</Text>
<View style={styles.actions}>
<Pressable
style={[styles.actionButton, styles.actionButtonPrimary]}
onPress={() => setIsDirectOpen(true)}
>
<Link2 size={18} color={theme.colors.palette.white} />
<Text style={[styles.actionText, styles.actionTextPrimary]}>Direct connection</Text>
</Pressable>
{Platform.OS !== "web" ? (
<Pressable
style={styles.actionButton}
onPress={() => router.push("/pair-scan")}
>
<QrCode size={18} color={theme.colors.foreground} />
<Text style={styles.actionText}>Scan QR code</Text>
</Pressable>
) : null}
</View>
<AddHostModal
visible={isDirectOpen}
onClose={() => setIsDirectOpen(false)}
onSaved={(profile) => {
onHostAdded?.(profile);
router.replace({ pathname: "/", params: { serverId: profile.id } });
}}
/>
</View>
);
}