Update files

This commit is contained in:
Mohamed Boudra
2026-02-05 12:17:50 +07:00
parent ae27a14e3b
commit 45869e699f
5 changed files with 142 additions and 101 deletions
+3 -1
View File
@@ -1,11 +1,13 @@
// App name and package ID are controlled by Gradle product flavors (dev/production) // App name and package ID are controlled by Gradle product flavors (dev/production)
// See plugins/with-android-product-flavors.js for flavor configuration // See plugins/with-android-product-flavors.js for flavor configuration
const pkg = require("./package.json");
export default { export default {
expo: { expo: {
name: "Paseo", name: "Paseo",
slug: "voice-mobile", slug: "voice-mobile",
version: "0.1.0", version: pkg.version,
orientation: "portrait", orientation: "portrait",
icon: "./assets/images/icon.png", icon: "./assets/images/icon.png",
scheme: "paseo", scheme: "paseo",
+114 -88
View File
@@ -13,7 +13,7 @@ import { router, useLocalSearchParams } from "expo-router";
import Constants from "expo-constants"; import Constants from "expo-constants";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Sun, Moon, Monitor, MoreVertical } from "lucide-react-native"; import { Sun, Moon, Monitor, MoreVertical, Globe } from "lucide-react-native";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context"; import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
@@ -126,16 +126,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
}, },
hostUrl: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontFamily: Fonts.mono,
},
hostConnections: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
},
hostError: { hostError: {
color: theme.colors.palette.red[300], color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
@@ -158,6 +148,24 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
}, },
connectionPill: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface3,
maxWidth: 170,
},
connectionText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
menuButton: { menuButton: {
width: 36, width: 36,
height: 32, height: 32,
@@ -390,6 +398,8 @@ export default function SettingsScreen() {
const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState<string | null>(null); const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState<string | null>(null);
const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState<string | null>(null); const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null); const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null);
const [pendingRemoveHost, setPendingRemoveHost] = useState<HostProfile | null>(null);
const [isRemovingHost, setIsRemovingHost] = useState(false);
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null); const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
const [editLabel, setEditLabel] = useState(""); const [editLabel, setEditLabel] = useState("");
const [isSavingEdit, setIsSavingEdit] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false);
@@ -441,6 +451,19 @@ export default function SettingsScreen() {
setEditLabel(""); setEditLabel("");
}, [isSavingEdit]); }, [isSavingEdit]);
const closeAddConnectionFlow = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(false);
setIsPasteLinkVisible(false);
setAddConnectionTargetServerId(null);
}, []);
const goBackToAddConnectionMethods = useCallback(() => {
setIsDirectHostVisible(false);
setIsPasteLinkVisible(false);
setIsAddHostMethodVisible(true);
}, []);
useEffect(() => { useEffect(() => {
const editHost = typeof params.editHost === "string" ? params.editHost.trim() : ""; const editHost = typeof params.editHost === "string" ? params.editHost.trim() : "";
if (!editHost) return; if (!editHost) return;
@@ -498,45 +521,9 @@ export default function SettingsScreen() {
[removeConnection] [removeConnection]
); );
const handleRemoveDaemon = useCallback( const handleRemoveDaemon = useCallback((profile: HostProfile) => {
(profile: HostProfile) => { setPendingRemoveHost(profile);
if (Platform.OS === "web") { }, []);
const hasBrowserConfirm =
typeof globalThis !== "undefined" &&
typeof (globalThis as any).confirm === "function";
const confirmed = hasBrowserConfirm ? (globalThis as any).confirm(`Remove ${profile.label}?`) : true;
if (!confirmed) return;
void removeHost(profile.serverId).catch((error) => {
console.error("[Settings] Failed to remove daemon", error);
Alert.alert("Error", "Unable to remove host");
});
return;
}
Alert.alert(
"Remove host",
`Remove ${profile.label}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Remove",
style: "destructive",
onPress: async () => {
try {
await removeHost(profile.serverId);
} catch (error) {
console.error("[Settings] Failed to remove daemon", error);
Alert.alert("Error", "Unable to remove host");
}
},
},
]
);
},
[removeHost]
);
const handleThemeChange = useCallback( const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => { (newTheme: AppSettings["theme"]) => {
@@ -641,13 +628,20 @@ export default function SettingsScreen() {
<AddHostMethodModal <AddHostMethodModal
visible={isAddHostMethodVisible} visible={isAddHostMethodVisible}
onClose={() => setIsAddHostMethodVisible(false)} onClose={closeAddConnectionFlow}
onDirectConnection={() => setIsDirectHostVisible(true)} onDirectConnection={() => {
onPasteLink={() => setIsPasteLinkVisible(true)} setIsAddHostMethodVisible(false);
setIsDirectHostVisible(true);
}}
onPasteLink={() => {
setIsAddHostMethodVisible(false);
setIsPasteLinkVisible(true);
}}
onScanQr={() => { onScanQr={() => {
const target = addConnectionTargetServerId; const target = addConnectionTargetServerId;
const source = target ? "editHost" : "settings"; const source = target ? "editHost" : "settings";
const qs = target ? `?source=${source}&targetServerId=${encodeURIComponent(target)}` : `?source=${source}`; const qs = target ? `?source=${source}&targetServerId=${encodeURIComponent(target)}` : `?source=${source}`;
closeAddConnectionFlow();
router.push(`/pair-scan${qs}`); router.push(`/pair-scan${qs}`);
}} }}
/> />
@@ -655,7 +649,8 @@ export default function SettingsScreen() {
<AddHostModal <AddHostModal
visible={isDirectHostVisible} visible={isDirectHostVisible}
targetServerId={addConnectionTargetServerId ?? undefined} targetServerId={addConnectionTargetServerId ?? undefined}
onClose={() => setIsDirectHostVisible(false)} onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => { onSaved={({ serverId, hostname, isNewHost }) => {
if (isNewHost) { if (isNewHost) {
setPendingNameHost({ serverId, hostname }); setPendingNameHost({ serverId, hostname });
@@ -666,7 +661,8 @@ export default function SettingsScreen() {
<PairLinkModal <PairLinkModal
visible={isPasteLinkVisible} visible={isPasteLinkVisible}
targetServerId={addConnectionTargetServerId ?? undefined} targetServerId={addConnectionTargetServerId ?? undefined}
onClose={() => setIsPasteLinkVisible(false)} onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => { onSaved={({ serverId, hostname, isNewHost }) => {
if (isNewHost) { if (isNewHost) {
setPendingNameHost({ serverId, hostname }); setPendingNameHost({ serverId, hostname });
@@ -688,6 +684,51 @@ export default function SettingsScreen() {
/> />
) : null} ) : null}
{pendingRemoveHost ? (
<AdaptiveModalSheet
title="Remove host"
visible
onClose={() => {
if (isRemovingHost) return;
setPendingRemoveHost(null);
}}
testID="remove-host-confirm-modal"
>
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 14 }}>
Remove {pendingRemoveHost.label}? This will delete its saved connections.
</Text>
<View style={[styles.formActionsRow, { marginTop: theme.spacing[4] }]}>
<Pressable
style={[styles.formButton, isRemovingHost && styles.disabled]}
onPress={() => setPendingRemoveHost(null)}
disabled={isRemovingHost}
>
<Text style={styles.formButtonText}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.formButton, isRemovingHost && styles.disabled, { backgroundColor: theme.colors.destructive }]}
onPress={() => {
const serverId = pendingRemoveHost.serverId;
setIsRemovingHost(true);
void removeHost(serverId)
.then(() => setPendingRemoveHost(null))
.catch((error) => {
console.error("[Settings] Failed to remove host", error);
Alert.alert("Error", "Unable to remove host");
})
.finally(() => setIsRemovingHost(false));
}}
disabled={isRemovingHost}
testID="remove-host-confirm"
>
<Text style={[styles.formButtonText, { color: theme.colors.palette.white }]}>
Remove
</Text>
</Pressable>
</View>
</AdaptiveModalSheet>
) : null}
<AdaptiveModalSheet <AdaptiveModalSheet
title="Edit host" title="Edit host"
visible={Boolean(editingDaemon)} visible={Boolean(editingDaemon)}
@@ -995,13 +1036,15 @@ function DaemonCard({
: statusTone === "error" : statusTone === "error"
? "rgba(248, 113, 113, 0.1)" ? "rgba(248, 113, 113, 0.1)"
: "rgba(161, 161, 170, 0.1)"; : "rgba(161, 161, 170, 0.1)";
const connectionBadge = (() => {
const connectionsSummary = (() => { if (!activeConnection) return null;
const parts = daemon.connections.map((conn) => { if (activeConnection.type === "relay") {
if (conn.type === "relay") return `relay:${conn.relayEndpoint}`; return { icon: <Globe size={12} color={theme.colors.foregroundMuted} />, text: "Relay" };
return `direct:${conn.endpoint}`; }
}); return {
return parts.join(" • "); icon: <Monitor size={12} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
};
})(); })();
return ( return (
@@ -1015,6 +1058,15 @@ function DaemonCard({
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text> <Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
</View> </View>
{connectionBadge ? (
<View style={styles.connectionPill}>
{connectionBadge.icon}
<Text style={styles.connectionText} numberOfLines={1}>
{connectionBadge.text}
</Text>
</View>
) : null}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
testID={`daemon-menu-trigger-${daemon.serverId}`} testID={`daemon-menu-trigger-${daemon.serverId}`}
@@ -1050,32 +1102,6 @@ function DaemonCard({
</DropdownMenu> </DropdownMenu>
</View> </View>
</View> </View>
<Text style={styles.hostUrl}>
{(() => {
if (connectionStatus === "online") {
if (activeConnection?.type === "relay") return "Connected via relay";
if (activeConnection?.type === "direct") return `Connected via ${activeConnection.display}`;
return "Connected";
}
if (connectionStatus === "connecting" && activeConnection) {
if (activeConnection.type === "relay") return "Trying relay";
return `Trying ${activeConnection.display}`;
}
if ((connectionStatus === "offline" || connectionStatus === "error") && activeConnection) {
if (activeConnection.type === "relay") return "Last tried: relay";
return `Last tried: ${activeConnection.display}`;
}
const relay = daemon.connections.find((c) => c.type === "relay");
const direct = daemon.connections.find((c) => c.type === "direct");
if (direct) return direct.endpoint;
if (relay) return "Relay";
return "";
})()}
</Text>
{connectionsSummary ? <Text style={styles.hostConnections}>{connectionsSummary}</Text> : null}
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null} {connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
</View> </View>
</View> </View>
@@ -48,19 +48,16 @@ export function AddHostMethodModal({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const handleDirect = useCallback(() => { const handleDirect = useCallback(() => {
onClose();
onDirectConnection(); onDirectConnection();
}, [onClose, onDirectConnection]); }, [onDirectConnection]);
const handleScan = useCallback(() => { const handleScan = useCallback(() => {
onClose();
onScanQr(); onScanQr();
}, [onClose, onScanQr]); }, [onScanQr]);
const handlePaste = useCallback(() => { const handlePaste = useCallback(() => {
onClose();
onPasteLink(); onPasteLink();
}, [onClose, onPasteLink]); }, [onPasteLink]);
return ( return (
<AdaptiveModalSheet title="Add connection" visible={visible} onClose={onClose} testID="add-host-method-modal"> <AdaptiveModalSheet title="Add connection" visible={visible} onClose={onClose} testID="add-host-method-modal">
+11 -3
View File
@@ -147,10 +147,11 @@ export interface AddHostModalProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
targetServerId?: string; targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void; onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
} }
export function AddHostModal({ visible, onClose, onSaved, targetServerId }: AddHostModalProps) { export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { daemons, upsertDirectConnection } = useDaemonRegistry(); const { daemons, upsertDirectConnection } = useDaemonRegistry();
const isMobile = const isMobile =
@@ -170,6 +171,13 @@ export function AddHostModal({ visible, onClose, onSaved, targetServerId }: AddH
onClose(); onClose();
}, [isSaving, onClose]); }, [isSaving, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
setEndpointRaw("");
setErrorMessage("");
(onCancel ?? onClose)();
}, [isSaving, onCancel, onClose]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (isSaving) return; if (isSaving) return;
@@ -256,8 +264,8 @@ export function AddHostModal({ visible, onClose, onSaved, targetServerId }: AddH
</View> </View>
<View style={styles.actions}> <View style={styles.actions}>
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving}> <Pressable style={styles.button} onPress={handleCancel} disabled={isSaving}>
<Text style={styles.buttonText}>Cancel</Text> <Text style={styles.buttonText}>{onCancel ? "Back" : "Cancel"}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
style={[styles.button, styles.primaryButton, isSaving ? { opacity: 0.7 } : null]} style={[styles.button, styles.primaryButton, isSaving ? { opacity: 0.7 } : null]}
@@ -69,10 +69,11 @@ export interface PairLinkModalProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
targetServerId?: string; targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void; onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
} }
export function PairLinkModal({ visible, onClose, onSaved, targetServerId }: PairLinkModalProps) { export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServerId }: PairLinkModalProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { daemons, upsertDaemonFromOfferUrl } = useDaemonRegistry(); const { daemons, upsertDaemonFromOfferUrl } = useDaemonRegistry();
const isMobile = const isMobile =
@@ -90,6 +91,13 @@ export function PairLinkModal({ visible, onClose, onSaved, targetServerId }: Pai
onClose(); onClose();
}, [isSaving, onClose]); }, [isSaving, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
setOfferUrl("");
setErrorMessage("");
(onCancel ?? onClose)();
}, [isSaving, onCancel, onClose]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (isSaving) return; if (isSaving) return;
const raw = offerUrl.trim(); const raw = offerUrl.trim();
@@ -178,14 +186,14 @@ export function PairLinkModal({ visible, onClose, onSaved, targetServerId }: Pai
<View style={styles.actions}> <View style={styles.actions}>
<Pressable <Pressable
style={styles.button} style={styles.button}
onPress={handleClose} onPress={handleCancel}
disabled={isSaving} disabled={isSaving}
testID="pair-link-cancel" testID="pair-link-cancel"
accessible accessible
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Cancel" accessibilityLabel="Cancel"
> >
<Text style={styles.buttonText}>Cancel</Text> <Text style={styles.buttonText}>{onCancel ? "Back" : "Cancel"}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
style={[styles.button, styles.primaryButton, isSaving ? { opacity: 0.7 } : null]} style={[styles.button, styles.primaryButton, isSaving ? { opacity: 0.7 } : null]}