diff --git a/companion/app/(tabs)/(availability)/_layout.tsx b/companion/app/(tabs)/(availability)/_layout.tsx
index ca5262062875ac..186d570bd3b86b 100644
--- a/companion/app/(tabs)/(availability)/_layout.tsx
+++ b/companion/app/(tabs)/(availability)/_layout.tsx
@@ -1,9 +1,9 @@
import { Stack } from "expo-router";
-export default function EventTypesLayout() {
+export default function AvailabilityLayout() {
return (
-
+
);
}
diff --git a/companion/app/(tabs)/(availability)/index.tsx b/companion/app/(tabs)/(availability)/index.tsx
index 650b0d5308ec44..25c7f46ce1fb0a 100644
--- a/companion/app/(tabs)/(availability)/index.tsx
+++ b/companion/app/(tabs)/(availability)/index.tsx
@@ -1,4 +1,5 @@
import { isLiquidGlassAvailable } from "expo-glass-effect";
+import { Image } from "expo-image";
import { Stack, useRouter } from "expo-router";
import { useState } from "react";
import { Alert, Platform, Pressable } from "react-native";
@@ -7,7 +8,6 @@ import { useCreateSchedule, useUserProfile } from "@/hooks";
import { CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
-import { Image } from "expo-image";
export default function Availability() {
const router = useRouter();
diff --git a/companion/app/(tabs)/(event-types)/_layout.tsx b/companion/app/(tabs)/(event-types)/_layout.tsx
index ca5262062875ac..710077f404ed93 100644
--- a/companion/app/(tabs)/(event-types)/_layout.tsx
+++ b/companion/app/(tabs)/(event-types)/_layout.tsx
@@ -3,7 +3,7 @@ import { Stack } from "expo-router";
export default function EventTypesLayout() {
return (
-
+
);
}
diff --git a/companion/app/(tabs)/(event-types)/index.ios.tsx b/companion/app/(tabs)/(event-types)/index.ios.tsx
index 3487ce603f6cf5..930ebf24f57633 100644
--- a/companion/app/(tabs)/(event-types)/index.ios.tsx
+++ b/companion/app/(tabs)/(event-types)/index.ios.tsx
@@ -3,6 +3,7 @@ import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
import * as Clipboard from "expo-clipboard";
import { isLiquidGlassAvailable } from "expo-glass-effect";
+import { Image } from "expo-image";
import { Stack, useRouter } from "expo-router";
import { useMemo, useState } from "react";
import {
@@ -29,12 +30,11 @@ import {
import { CalComAPIService, type EventType } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
+import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { getEventDuration } from "@/utils/getEventDuration";
import { offlineAwareRefresh } from "@/utils/network";
import { normalizeMarkdown } from "@/utils/normalizeMarkdown";
import { slugify } from "@/utils/slugify";
-import { Image } from "expo-image";
-import { getAvatarUrl } from "@/utils/getAvatarUrl";
export default function EventTypesIOS() {
const router = useRouter();
diff --git a/companion/app/(tabs)/(event-types)/index.tsx b/companion/app/(tabs)/(event-types)/index.tsx
index 25074190362706..4fd61d6038e0e3 100644
--- a/companion/app/(tabs)/(event-types)/index.tsx
+++ b/companion/app/(tabs)/(event-types)/index.tsx
@@ -20,6 +20,17 @@ import { EventTypeListItem } from "@/components/event-type-list-item/EventTypeLi
import { FullScreenModal } from "@/components/FullScreenModal";
import { Header } from "@/components/Header";
import { LoadingSpinner } from "@/components/LoadingSpinner";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Text as AlertDialogText } from "@/components/ui/text";
import {
useCreateEventType,
useDeleteEventType,
@@ -42,6 +53,7 @@ export default function EventTypes() {
// Modal state for creating new event type
const [showCreateModal, setShowCreateModal] = useState(false);
const [newEventTitle, setNewEventTitle] = useState("");
+ const [titleError, setTitleError] = useState("");
// Use React Query hooks
const {
@@ -130,14 +142,8 @@ export default function EventTypes() {
return;
}
- if (Platform.OS !== "ios") {
- // Fallback for non-iOS platforms (Android)
- Alert.alert(eventType.title, eventType.description || "", [
- { text: "Cancel", style: "cancel" },
- { text: "Edit", onPress: () => handleEdit(eventType) },
- { text: "Duplicate", onPress: () => handleDuplicate(eventType) },
- { text: "Delete", style: "destructive", onPress: () => handleDelete(eventType) },
- ]);
+ // Android handles long-press via DropdownMenu in EventTypeListItem.android.tsx
+ if (Platform.OS === "android") {
return;
}
@@ -216,6 +222,42 @@ export default function EventTypes() {
};
const handleDelete = (eventType: EventType) => {
+ // Use native Alert.alert on Android for simple yes/no confirmation
+ if (Platform.OS === "android") {
+ Alert.alert(
+ "Delete Event Type",
+ `This will permanently delete the "${eventType.title}" event type. This action cannot be undone.`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete",
+ style: "destructive",
+ onPress: () => {
+ deleteEventTypeMutation(eventType.id, {
+ onSuccess: () => {
+ Alert.alert("Success", "Event type deleted successfully");
+ },
+ onError: (error) => {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error("Failed to delete event type", message);
+ if (__DEV__) {
+ const stack = error instanceof Error ? error.stack : undefined;
+ console.debug("[EventTypes] deleteEventType failed", {
+ message,
+ stack,
+ });
+ }
+ showErrorAlert("Error", "Failed to delete event type. Please try again.");
+ },
+ });
+ },
+ },
+ ]
+ );
+ return;
+ }
+
+ // Use custom modal for iOS and web
setEventTypeToDelete(eventType);
setShowDeleteModal(true);
};
@@ -240,7 +282,10 @@ export default function EventTypes() {
console.error("Failed to delete event type", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
- console.debug("[EventTypes] deleteEventType failed", { message, stack });
+ console.debug("[EventTypes] deleteEventType failed", {
+ message,
+ stack,
+ });
}
if (Platform.OS === "web") {
showToastMessage("Failed to delete event type");
@@ -285,7 +330,10 @@ export default function EventTypes() {
console.error("Failed to duplicate event type", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
- console.debug("[EventTypes] duplicateEventType failed", { message, stack });
+ console.debug("[EventTypes] duplicateEventType failed", {
+ message,
+ stack,
+ });
}
if (Platform.OS === "web") {
showToastMessage("Failed to duplicate event type");
@@ -328,17 +376,30 @@ export default function EventTypes() {
const handleCloseCreateModal = () => {
setShowCreateModal(false);
setNewEventTitle("");
+ setTitleError("");
};
const handleCreateEventType = () => {
+ // Clear previous error
+ setTitleError("");
+
if (!newEventTitle.trim()) {
- Alert.alert("Error", "Please enter a title for your event type");
+ // Use inline error for Android AlertDialog, Alert for others
+ if (Platform.OS === "android") {
+ setTitleError("Please enter a title for your event type");
+ } else {
+ Alert.alert("Error", "Please enter a title for your event type");
+ }
return;
}
const autoSlug = slugify(newEventTitle.trim());
if (!autoSlug) {
- Alert.alert("Error", "Title must contain at least one letter or number");
+ if (Platform.OS === "android") {
+ setTitleError("Title must contain at least one letter or number");
+ } else {
+ Alert.alert("Error", "Title must contain at least one letter or number");
+ }
return;
}
@@ -371,7 +432,10 @@ export default function EventTypes() {
console.error("Failed to create event type", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
- console.debug("[EventTypes] createEventType failed", { message, stack });
+ console.debug("[EventTypes] createEventType failed", {
+ message,
+ stack,
+ });
}
showErrorAlert("Error", "Failed to create event type. Please try again.");
},
@@ -537,74 +601,133 @@ export default function EventTypes() {
- {/* Create Event Type Modal */}
-
- {
+ if (!open) handleCloseCreateModal();
+ }}
+ >
+
+
+
+
+ Add a new event type
+
+
+
+
+ Set up event types to offer different types of meetings.
+
+
+
+
+ {/* Title Input */}
+
+ Title
+ {
+ setNewEventTitle(text);
+ if (titleError) setTitleError("");
+ }}
+ autoFocus
+ autoCapitalize="words"
+ returnKeyType="done"
+ onSubmitEditing={handleCreateEventType}
+ />
+ {titleError ? (
+
+ {titleError}
+
+ ) : null}
+
+
+
+
+ Cancel
+
+
+ Continue
+
+
+
+
+ ) : (
+
e.stopPropagation()}
- style={shadows.xl()}
+ onPress={handleCloseCreateModal}
>
- {/* Header */}
-
-
- Add a new event type
-
-
- Set up event types to offer different types of meetings.
-
-
+ e.stopPropagation()}
+ style={shadows.xl()}
+ >
+ {/* Header */}
+
+
+ Add a new event type
+
+
+ Set up event types to offer different types of meetings.
+
+
- {/* Content */}
-
- {/* Title */}
-
- Title
-
+ {/* Content */}
+
+ {/* Title */}
+
+ Title
+
+
-
- {/* Footer */}
-
-
-
- Close
-
-
- Continue
-
+ {/* Footer */}
+
+
+
+ Close
+
+
+ Continue
+
+
-
+
-
-
+
+ )}
{/* Action Modal for Web Platform */}
{content}
+
);
}
diff --git a/companion/bun.lock b/companion/bun.lock
index 545bcd8a98b35f..61803209ddd514 100644
--- a/companion/bun.lock
+++ b/companion/bun.lock
@@ -9,9 +9,14 @@
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-segmented-control/segmented-control": "2.5.7",
+ "@rn-primitives/alert-dialog": "^1.2.0",
+ "@rn-primitives/dropdown-menu": "^1.2.0",
+ "@rn-primitives/slot": "^1.2.0",
"@tanstack/react-query": "5.62.0",
"@tanstack/react-query-persist-client": "5.62.0",
"base64-js": "1.5.1",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
"expo": "55.0.0-canary-20251230-fc48ddc",
"expo-auth-session": "7.0.11-canary-20251230-fc48ddc",
"expo-clipboard": "9.0.0-canary-20251230-fc48ddc",
@@ -28,6 +33,7 @@
"expo-splash-screen": "31.0.14-canary-20251230-fc48ddc",
"expo-status-bar": "3.0.10-canary-20251230-fc48ddc",
"expo-web-browser": "15.0.11-canary-20251230-fc48ddc",
+ "lucide-react-native": "^0.562.0",
"nativewind": "4.2.1",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -39,6 +45,8 @@
"react-native-svg": "15.12.1",
"react-native-web": "0.21.2",
"react-native-worklets": "0.7.1",
+ "tailwind-merge": "^3.4.0",
+ "tailwindcss-animate": "^1.0.7",
},
"devDependencies": {
"@biomejs/biome": "2.3.10",
@@ -415,6 +423,14 @@
"@expo/xcpretty": ["@expo/xcpretty@4.3.2", "", { "dependencies": { "@babel/code-frame": "7.10.4", "chalk": "^4.1.0", "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw=="],
+ "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
+
+ "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
+
+ "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
+
+ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
+
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -473,6 +489,10 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
+ "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
+
+ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
+
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
@@ -485,12 +505,18 @@
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
+ "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
+
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
+ "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
+
+ "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
+
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
@@ -513,6 +539,12 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
+ "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
+
+ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
+
+ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
+
"@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="],
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
@@ -555,6 +587,20 @@
"@react-navigation/routers": ["@react-navigation/routers@7.5.2", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-kymreY5aeTz843E+iPAukrsOtc7nabAH6novtAPREmmGu77dQpfxPB2ZWpKb5nRErIRowp1kYRoN2Ckl+S6JYw=="],
+ "@rn-primitives/alert-dialog": ["@rn-primitives/alert-dialog@1.2.0", "", { "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.14", "@rn-primitives/hooks": "1.3.0", "@rn-primitives/slot": "1.2.0", "@rn-primitives/types": "1.2.0" }, "peerDependencies": { "@rn-primitives/portal": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-/XxvQVRMnIyQo29iuQ631CHjghGKY8U4k3gbsS2MCGP0hglZVFoBRiAF3Bgyr16LxWRu1DoPltvzwOrUCsB+YQ=="],
+
+ "@rn-primitives/dropdown-menu": ["@rn-primitives/dropdown-menu@1.2.0", "", { "dependencies": { "@radix-ui/react-dropdown-menu": "^2.1.15", "@rn-primitives/hooks": "1.3.0", "@rn-primitives/slot": "1.2.0", "@rn-primitives/types": "1.2.0", "@rn-primitives/utils": "1.2.0" }, "peerDependencies": { "@rn-primitives/portal": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-TJDDr8VQfw9CRZ7xZ6kBYLVMqL1xFVC5ZZ4sfRmWP6PCT0lNks4XqGuTFLeVVlNLPSmzt9GKC2DZqzDXui8/NQ=="],
+
+ "@rn-primitives/hooks": ["@rn-primitives/hooks@1.3.0", "", { "dependencies": { "@rn-primitives/types": "1.2.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-BR97reSu7uVDpyMeQdRJHT0w8KdS6jdYnOL6xQtqS2q3H6N7vXBlX4LFERqJZphD+aziJFIAJ3HJF1vtt6XlpQ=="],
+
+ "@rn-primitives/portal": ["@rn-primitives/portal@1.3.0", "", { "dependencies": { "zustand": "^5.0.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-a2DSce7TcSfcs0cCngLadAJOvx/+mdH9NRu+GxkX8NPRsGGhJvDEOqouMgDqLwx7z9mjXoUaZcwaVcemUSW9/A=="],
+
+ "@rn-primitives/slot": ["@rn-primitives/slot@1.2.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-cpbn+JLjSeq3wcA4uqgFsUimMrWYWx2Ks7r5rkwd1ds1utxynsGkLOKpYVQkATwWrYhtcoF1raxIKEqXuMN+/w=="],
+
+ "@rn-primitives/types": ["@rn-primitives/types@1.2.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-b+6zKgdKVqAfaFPSfhwlQL0dnPQXPpW890m3eguC0VDI1eOsoEvUfVb6lmgH4bum9MmI0xymq4tOUI/fsKLoCQ=="],
+
+ "@rn-primitives/utils": ["@rn-primitives/utils@1.2.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native", "react-native-web"] }, "sha512-vLXV5NuxIHDeb4Bw57FzdUh89/g8gz6GERm8TsbJaSUPsDXfnC/ffeYiZJb0LxNteKE3Nr8na4Jy2n26tFil7w=="],
+
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.3", "", { "os": "android", "cpu": "arm64" }, "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w=="],
@@ -835,6 +881,8 @@
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
+ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
@@ -849,6 +897,8 @@
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -1413,6 +1463,8 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+ "lucide-react-native": ["lucide-react-native@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-native": "*", "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" } }, "sha512-ZF2ok8SzyUaiCIrLGqYh/6SPs+huVzbZOCv0i411L4+oP3tJgQvvKePiVgWCioa7HsT2xaJZSrdd92cuB2/+ew=="],
+
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
@@ -1879,8 +1931,12 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
+ "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
+
"tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="],
+ "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
+
"tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="],
"temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="],
@@ -2067,6 +2123,8 @@
"zod-validation-error": ["zod-validation-error@3.5.4", "", { "peerDependencies": { "zod": "^3.24.4" } }, "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw=="],
+ "zustand": ["zustand@5.0.9", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg=="],
+
"@aklinker1/rollup-plugin-visualizer/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -2163,10 +2221,14 @@
"@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="],
+ "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@react-native/babel-plugin-codegen/@react-native/codegen": ["@react-native/codegen@0.81.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.29.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g=="],
diff --git a/companion/components.json b/companion/components.json
new file mode 100644
index 00000000000000..a45eb48ecf5e0e
--- /dev/null
+++ b/companion/components.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "global.css",
+ "baseColor": "neutral",
+ "cssVariables": true
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/companion/components/ScreenWrapper.tsx b/companion/components/ScreenWrapper.tsx
new file mode 100644
index 00000000000000..1e4c7a47972a48
--- /dev/null
+++ b/companion/components/ScreenWrapper.tsx
@@ -0,0 +1,121 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { ReactNode } from "react";
+import { Text, TouchableOpacity, View } from "react-native";
+import { EmptyScreen } from "./EmptyScreen";
+import { Header } from "./Header";
+import { LoadingSpinner } from "./LoadingSpinner";
+
+type IoniconName = keyof typeof Ionicons.glyphMap;
+
+interface EmptyStateConfig {
+ icon: IoniconName;
+ headline: string;
+ description: string;
+ buttonText?: string;
+ onButtonPress?: () => void;
+}
+
+interface ScreenWrapperProps {
+ /** Whether data is currently loading */
+ loading?: boolean;
+ /** Error message to display, if any */
+ error?: string | null;
+ /** Callback for retry button in error state */
+ onRetry?: () => void;
+ /** Custom title for error state (default: "Unable to load data") */
+ errorTitle?: string;
+ /** Whether to show empty state */
+ isEmpty?: boolean;
+ /** Configuration for empty state display */
+ emptyProps?: EmptyStateConfig;
+ /** Whether to show the header (default: true) */
+ showHeader?: boolean;
+ /** Content to render when not in loading/error/empty state */
+ children: ReactNode;
+}
+
+/**
+ * Wrapper component that handles common screen states: loading, error, and empty.
+ * Renders the appropriate UI based on the current state.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ * ```
+ */
+export function ScreenWrapper({
+ loading,
+ error,
+ onRetry,
+ errorTitle = "Unable to load data",
+ isEmpty,
+ emptyProps,
+ showHeader = true,
+ children,
+}: ScreenWrapperProps) {
+ if (loading) {
+ return (
+
+ {showHeader && }
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {showHeader && }
+
+
+
+ {errorTitle}
+
+ {error}
+ {onRetry && (
+
+ Retry
+
+ )}
+
+
+ );
+ }
+
+ if (isEmpty && emptyProps) {
+ return (
+
+ {showHeader && }
+
+
+
+
+ );
+ }
+
+ return (
+
+ {showHeader && }
+ {children}
+
+ );
+}
+
+export type { EmptyStateConfig, ScreenWrapperProps };
diff --git a/companion/components/SearchHeader.tsx b/companion/components/SearchHeader.tsx
new file mode 100644
index 00000000000000..4ae12e51912700
--- /dev/null
+++ b/companion/components/SearchHeader.tsx
@@ -0,0 +1,60 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Text, TextInput, TouchableOpacity, View } from "react-native";
+
+interface SearchHeaderProps {
+ /** Current search query value */
+ searchQuery: string;
+ /** Callback when search query changes */
+ onSearchChange: (query: string) => void;
+ /** Placeholder text for search input (default: "Search") */
+ placeholder?: string;
+ /** Callback when New button is pressed */
+ onNewPress: () => void;
+ /** Text for the New button (default: "New") */
+ newButtonText?: string;
+}
+
+/**
+ * Reusable search header component with a search input and "New" button.
+ * Used consistently across Android list screens.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function SearchHeader({
+ searchQuery,
+ onSearchChange,
+ placeholder = "Search",
+ onNewPress,
+ newButtonText = "New",
+}: SearchHeaderProps) {
+ return (
+
+
+
+
+ {newButtonText}
+
+
+ );
+}
+
+export type { SearchHeaderProps };
diff --git a/companion/components/availability-list-item/AvailabilityListItem.android.tsx b/companion/components/availability-list-item/AvailabilityListItem.android.tsx
new file mode 100644
index 00000000000000..74be9a9b2f487e
--- /dev/null
+++ b/companion/components/availability-list-item/AvailabilityListItem.android.tsx
@@ -0,0 +1,136 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Pressable, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Text } from "@/components/ui/text";
+import type { AvailabilityListItemProps } from "./AvailabilityListItem";
+import { AvailabilitySlots, ScheduleName, TimeZoneRow } from "./AvailabilityListItemParts";
+
+export const AvailabilityListItem = ({
+ item: schedule,
+ index: _index,
+ handleSchedulePress,
+ handleScheduleLongPress: _handleScheduleLongPress,
+ setSelectedSchedule: _setSelectedSchedule,
+ setShowActionsModal: _setShowActionsModal,
+ onDuplicate,
+ onDelete,
+ onSetAsDefault,
+}: AvailabilityListItemProps) => {
+ const insets = useSafeAreaInsets();
+
+ const contentInsets = {
+ top: insets.top,
+ bottom: insets.bottom,
+ left: 12,
+ right: 12,
+ };
+
+ // Define dropdown menu actions based on schedule state
+ type DropdownAction = {
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ variant?: "default" | "destructive";
+ };
+
+ const scheduleActions: DropdownAction[] = [
+ ...(!schedule.isDefault && onSetAsDefault
+ ? [
+ {
+ label: "Set as Default",
+ icon: "star-outline" as const,
+ onPress: () => onSetAsDefault(schedule),
+ variant: "default" as const,
+ },
+ ]
+ : []),
+ ...(onDuplicate
+ ? [
+ {
+ label: "Duplicate",
+ icon: "copy-outline" as const,
+ onPress: () => onDuplicate(schedule),
+ variant: "default" as const,
+ },
+ ]
+ : []),
+ ...(onDelete
+ ? [
+ {
+ label: "Delete",
+ icon: "trash-outline" as const,
+ onPress: () => onDelete(schedule),
+ variant: "destructive" as const,
+ },
+ ]
+ : []),
+ ];
+
+ // Find the index where destructive actions start
+ const destructiveStartIndex = scheduleActions.findIndex(
+ (action) => action.variant === "destructive"
+ );
+
+ return (
+
+
+ handleSchedulePress(schedule)}
+ className="mr-4 flex-1"
+ android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
+ >
+
+
+
+
+
+
+
+ {/* Dropdown Menu */}
+
+
+
+
+
+
+
+
+ {scheduleActions.map((action, index) => (
+
+ {/* Add separator before destructive actions */}
+ {index === destructiveStartIndex && destructiveStartIndex > 0 && (
+
+ )}
+
+
+
+ {action.label}
+
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/companion/components/booking-list-item/BookingListItem.android.tsx b/companion/components/booking-list-item/BookingListItem.android.tsx
new file mode 100644
index 00000000000000..c52a67629ea1c9
--- /dev/null
+++ b/companion/components/booking-list-item/BookingListItem.android.tsx
@@ -0,0 +1,227 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Pressable, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Text } from "@/components/ui/text";
+import { getBookingActions } from "@/utils/booking-actions";
+import {
+ BadgesRow,
+ BookingDescription,
+ BookingTitle,
+ ConfirmRejectButtons,
+ HostAndAttendees,
+ MeetingLink,
+ TimeAndDateRow,
+} from "./BookingListItemParts";
+import type { BookingListItemProps } from "./types";
+import { useBookingListItemData } from "./useBookingListItemData";
+
+export const BookingListItem: React.FC = ({
+ booking,
+ userEmail,
+ isConfirming,
+ isDeclining,
+ onPress,
+ onLongPress: _onLongPress,
+ onConfirm,
+ onReject,
+ onActionsPress: _onActionsPress,
+ onReschedule,
+ onEditLocation,
+ onAddGuests,
+ onViewRecordings,
+ onMeetingSessionDetails,
+ onMarkNoShow,
+ onReportBooking,
+ onCancelBooking,
+}) => {
+ const {
+ isUpcoming,
+ isPending,
+ isCancelled,
+ isRejected,
+ hostAndAttendeesDisplay,
+ meetingInfo,
+ hasNoShowAttendee,
+ formattedDate,
+ formattedTimeRange,
+ } = useBookingListItemData(booking, userEmail);
+
+ const insets = useSafeAreaInsets();
+
+ const contentInsets = {
+ top: insets.top,
+ bottom: insets.bottom,
+ left: 12,
+ right: 12,
+ };
+
+ // Use centralized action gating for consistency
+ const actions = React.useMemo(() => {
+ return getBookingActions({
+ booking,
+ eventType: undefined,
+ currentUserId: undefined,
+ currentUserEmail: userEmail,
+ isOnline: true,
+ });
+ }, [booking, userEmail]);
+
+ // Define dropdown menu actions based on booking state
+ type DropdownAction = {
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ variant?: "default" | "destructive";
+ };
+
+ const allActions: (DropdownAction & { visible: boolean })[] = [
+ // Edit Event Section
+ {
+ label: "Reschedule Booking",
+ icon: "calendar-outline",
+ onPress: () => onReschedule?.(booking),
+ variant: "default" as const,
+ visible: isUpcoming && !isCancelled && !isPending && !!onReschedule,
+ },
+ {
+ label: "Edit Location",
+ icon: "location-outline",
+ onPress: () => onEditLocation?.(booking),
+ variant: "default" as const,
+ visible: isUpcoming && !isCancelled && !isPending && !!onEditLocation,
+ },
+ {
+ label: "Add Guests",
+ icon: "person-add-outline",
+ onPress: () => onAddGuests?.(booking),
+ variant: "default" as const,
+ visible: isUpcoming && !isCancelled && !isPending && !!onAddGuests,
+ },
+ // After Event Section
+ {
+ label: "View Recordings",
+ icon: "videocam-outline",
+ onPress: () => onViewRecordings?.(booking),
+ variant: "default" as const,
+ visible:
+ actions.viewRecordings.visible && actions.viewRecordings.enabled && !!onViewRecordings,
+ },
+ {
+ label: "Meeting Session Details",
+ icon: "information-circle-outline",
+ onPress: () => onMeetingSessionDetails?.(booking),
+ variant: "default" as const,
+ visible:
+ actions.meetingSessionDetails.visible &&
+ actions.meetingSessionDetails.enabled &&
+ !!onMeetingSessionDetails,
+ },
+ {
+ label: "Mark as No-Show",
+ icon: "eye-off-outline",
+ onPress: () => onMarkNoShow?.(booking),
+ variant: "default" as const,
+ visible: actions.markNoShow.visible && actions.markNoShow.enabled && !!onMarkNoShow,
+ },
+ // Other Actions
+ {
+ label: "Report Booking",
+ icon: "flag-outline",
+ onPress: () => onReportBooking?.(booking),
+ variant: "destructive" as const,
+ visible: !!onReportBooking,
+ },
+ {
+ label: "Cancel Event",
+ icon: "close-circle-outline",
+ onPress: () => onCancelBooking?.(booking),
+ variant: "destructive" as const,
+ visible: isUpcoming && !isCancelled && !!onCancelBooking,
+ },
+ ];
+
+ const visibleActions = allActions.filter((action) => action.visible);
+
+ // Find the index where destructive actions start
+ const destructiveStartIndex = visibleActions.findIndex(
+ (action) => action.variant === "destructive"
+ );
+
+ return (
+
+ onPress(booking)}
+ style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}
+ className="active:bg-cal-bg-secondary"
+ android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
+ >
+
+
+
+
+
+
+
+
+
+
+ {/* Dropdown Menu - only show when there are visible actions */}
+ {visibleActions.length > 0 && (
+
+
+
+
+
+
+
+
+ {visibleActions.map((action, index) => (
+
+ {/* Add separator before destructive actions */}
+ {index === destructiveStartIndex && destructiveStartIndex > 0 && (
+
+ )}
+
+
+
+ {action.label}
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+};
diff --git a/companion/components/booking-list-screen/BookingListScreen.tsx b/companion/components/booking-list-screen/BookingListScreen.tsx
index d49bf7ad410379..a16572c9033bb5 100644
--- a/companion/components/booking-list-screen/BookingListScreen.tsx
+++ b/companion/components/booking-list-screen/BookingListScreen.tsx
@@ -1,11 +1,31 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { Activity, useMemo, useState } from "react";
-import { Alert, FlatList, RefreshControl, ScrollView, Text, View } from "react-native";
+import {
+ Alert,
+ FlatList,
+ Platform,
+ RefreshControl,
+ ScrollView,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
import { BookingListItem } from "@/components/booking-list-item/BookingListItem";
import { BookingModals } from "@/components/booking-modals/BookingModals";
import { EmptyScreen } from "@/components/EmptyScreen";
import { LoadingSpinner } from "@/components/LoadingSpinner";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Text as UIText } from "@/components/ui/text";
import { useAuth } from "@/contexts/AuthContext";
import {
type BookingFilter,
@@ -100,6 +120,11 @@ export const BookingListScreen: React.FC = ({
showRejectModal,
rejectReason,
setRejectReason,
+ showCancelModal,
+ cancelReason,
+ setCancelReason,
+ handleSubmitCancel,
+ handleCloseCancelModal,
selectedBooking,
setSelectedBooking,
handleBookingPress,
@@ -463,7 +488,49 @@ export const BookingListScreen: React.FC = ({
}}
/>
- {/* Action Modals */}
+ {/* Cancel Event AlertDialog for Android */}
+ {Platform.OS === "android" && (
+
+
+
+
+ Cancel event
+
+
+
+ Cancellation reason will be shared with guests
+
+
+
+
+ {/* Reason Input */}
+
+ Reason for cancellation
+
+
+
+
+
+ Nevermind
+
+
+ Cancel event
+
+
+
+
+ )}
>
);
};
diff --git a/companion/components/event-type-list-item/EventTypeListItem.android.tsx b/companion/components/event-type-list-item/EventTypeListItem.android.tsx
new file mode 100644
index 00000000000000..516760c2974818
--- /dev/null
+++ b/companion/components/event-type-list-item/EventTypeListItem.android.tsx
@@ -0,0 +1,120 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Pressable, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Text } from "@/components/ui/text";
+import {
+ DurationBadge,
+ EventTypeDescription,
+ EventTypeTitle,
+ PriceAndConfirmationBadges,
+} from "./EventTypeListItemParts";
+import type { EventTypeListItemProps } from "./types";
+import { useEventTypeListItemData } from "./useEventTypeListItemData";
+
+export const EventTypeListItem = ({
+ item,
+ index,
+ filteredEventTypes,
+ copiedEventTypeId: _copiedEventTypeId,
+ handleEventTypePress,
+ handleEventTypeLongPress: _handleEventTypeLongPress,
+ handleCopyLink,
+ handlePreview,
+ onEdit,
+ onDuplicate,
+ onDelete,
+}: EventTypeListItemProps) => {
+ const { formattedDuration, normalizedDescription, hasPrice, formattedPrice } =
+ useEventTypeListItemData(item);
+ const isLast = index === filteredEventTypes.length - 1;
+ const insets = useSafeAreaInsets();
+
+ const contentInsets = {
+ top: insets.top,
+ bottom: insets.bottom,
+ left: 12,
+ right: 12,
+ };
+
+ return (
+
+
+ handleEventTypePress(item)}
+ className="mr-4 flex-1"
+ android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
+ >
+
+
+
+
+
+
+ {/* Dropdown Menu - Single Button */}
+
+
+
+
+
+
+
+
+ {/* Preview & Copy Actions */}
+ handlePreview(item)}>
+
+ Preview
+
+
+ handleCopyLink(item)}>
+
+ Copy link
+
+
+
+
+ {/* Edit & Duplicate Actions */}
+ onEdit?.(item)}>
+
+ Edit
+
+
+ onDuplicate?.(item)}>
+
+ Duplicate
+
+
+
+
+ {/* Delete Action - Destructive */}
+ onDelete?.(item)}>
+
+ Delete
+
+
+
+
+
+ );
+};
diff --git a/companion/components/screens/AvailabilityListScreen.tsx b/companion/components/screens/AvailabilityListScreen.tsx
index 42c0ec17832b9c..ab00cbc1661d5a 100644
--- a/companion/components/screens/AvailabilityListScreen.tsx
+++ b/companion/components/screens/AvailabilityListScreen.tsx
@@ -18,6 +18,17 @@ import { EmptyScreen } from "@/components/EmptyScreen";
import { FullScreenModal } from "@/components/FullScreenModal";
import { Header } from "@/components/Header";
import { LoadingSpinner } from "@/components/LoadingSpinner";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Text as AlertDialogText } from "@/components/ui/text";
import {
useCreateSchedule,
useDeleteSchedule,
@@ -45,6 +56,7 @@ export function AvailabilityListScreen({
}: AvailabilityListScreenProps) {
const router = useRouter();
const [newScheduleName, setNewScheduleName] = useState("");
+ const [nameError, setNameError] = useState("");
const [showActionsModal, setShowActionsModal] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
@@ -103,11 +115,18 @@ export function AvailabilityListScreen({
style?: "destructive" | "cancel" | "default";
}[] = [];
if (!schedule.isDefault) {
- options.push({ text: "Set as default", onPress: () => handleSetAsDefault(schedule) });
+ options.push({
+ text: "Set as default",
+ onPress: () => handleSetAsDefault(schedule),
+ });
}
options.push(
{ text: "Duplicate", onPress: () => handleDuplicate(schedule) },
- { text: "Delete", style: "destructive" as const, onPress: () => handleDelete(schedule) }
+ {
+ text: "Delete",
+ style: "destructive" as const,
+ onPress: () => handleDelete(schedule),
+ }
);
// Android Alert automatically adds cancel, so we don't need to include it explicitly
Alert.alert(schedule.name, "", options);
@@ -218,12 +237,21 @@ export function AvailabilityListScreen({
const handleCreateNew = () => {
setNewScheduleName("");
+ setNameError("");
onShowCreateModalChange(true);
};
const handleCreateSchedule = async () => {
+ // Clear previous error
+ setNameError("");
+
if (!newScheduleName.trim()) {
- Alert.alert("Error", "Please enter a schedule name");
+ // Use inline error for Android AlertDialog, Alert for others
+ if (Platform.OS === "android") {
+ setNameError("Please enter a schedule name");
+ } else {
+ Alert.alert("Error", "Please enter a schedule name");
+ }
return;
}
@@ -270,7 +298,10 @@ export function AvailabilityListScreen({
console.error("Failed to create schedule", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
- console.debug("[AvailabilityListScreen] createSchedule failed", { message, stack });
+ console.debug("[AvailabilityListScreen] createSchedule failed", {
+ message,
+ stack,
+ });
}
showErrorAlert("Error", "Failed to create schedule. Please try again.");
},
@@ -417,73 +448,132 @@ export function AvailabilityListScreen({
- {/* Create Schedule Modal */}
- onShowCreateModalChange(false)}
- >
- onShowCreateModalChange(false)}
+ {/* Create Schedule Modal - Android uses AlertDialog */}
+ {Platform.OS === "android" ? (
+
+
+
+
+
+ Add a new schedule
+
+
+
+
+ Create a new availability schedule.
+
+
+
+
+ {/* Name Input */}
+
+ Name
+ {
+ setNewScheduleName(text);
+ if (nameError) setNameError("");
+ }}
+ autoFocus
+ autoCapitalize="words"
+ returnKeyType="done"
+ onSubmitEditing={handleCreateSchedule}
+ />
+ {nameError ? (
+ {nameError}
+ ) : null}
+
+
+
+ {
+ onShowCreateModalChange(false);
+ setNewScheduleName("");
+ setNameError("");
+ }}
+ disabled={creating}
+ >
+ Cancel
+
+
+ Continue
+
+
+
+
+ ) : (
+ onShowCreateModalChange(false)}
>
e.stopPropagation()}
- style={shadows.xl()}
+ onPress={() => onShowCreateModalChange(false)}
>
- {/* Header */}
-
- Add a new schedule
-
+ e.stopPropagation()}
+ style={shadows.xl()}
+ >
+ {/* Header */}
+
+ Add a new schedule
+
- {/* Content */}
-
-
- Name
-
+ {/* Content */}
+
+
+ Name
+
+
-
- {/* Footer */}
-
-
- {
- onShowCreateModalChange(false);
- setNewScheduleName("");
- }}
- disabled={creating}
- >
- Close
-
-
- Continue
-
+ {/* Footer */}
+
+
+ {
+ onShowCreateModalChange(false);
+ setNewScheduleName("");
+ }}
+ disabled={creating}
+ >
+ Close
+
+
+ Continue
+
+
-
+
-
-
+
+ )}
{/* Schedule Actions Modal */}
{
+ if (!dateString) return "";
+ const date = new Date(dateString);
+ if (Number.isNaN(date.getTime())) return "";
+ return date.toLocaleDateString("en-US", {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+};
+
+// Format time: "9:40pm - 10:00pm"
+const formatTime12Hour = (dateString: string): string => {
+ if (!dateString) return "";
+ const date = new Date(dateString);
+ if (Number.isNaN(date.getTime())) return "";
+ const hours = date.getHours();
+ const minutes = date.getMinutes();
+ const period = hours >= 12 ? "pm" : "am";
+ const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
+ const minStr = minutes.toString().padStart(2, "0");
+ return `${hour12}:${minStr}${period}`;
+};
+
+// Get user's local timezone for display
+const getTimezone = (): string => {
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ return timeZone || "";
+};
+
+// Get initials from a name(e.g., "Keith Williams" -> "KW", "Dhairyashil Shinde" -> "DS")
+const getInitials = (name: string): string => {
+ if (!name) return "";
+ const parts = name.trim().split(/\s+/);
+ if (parts.length === 0) return "";
+ if (parts.length === 1) {
+ return parts[0].charAt(0).toUpperCase();
+ }
+ // Get first letter of first name and first letter of last name
+ return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
+};
+
+// Get location provider info
+const getLocationProvider = (location: string | undefined, metadata?: Record) => {
+ // Check metadata for videoCallUrl first
+ const videoCallUrl = metadata?.videoCallUrl;
+ const locationToCheck = videoCallUrl || location;
+
+ if (!locationToCheck) return null;
+
+ // Check if it's a video call URL
+ if (typeof locationToCheck === "string" && locationToCheck.startsWith("http")) {
+ // Try to detect provider from URL
+ if (locationToCheck.includes("cal.com/video") || locationToCheck.includes("cal-video")) {
+ const iconUrl = getAppIconUrl("daily_video", "cal-video");
+ return {
+ label: "Cal Video",
+ iconUrl: iconUrl || "https://app.cal.com/app-store/dailyvideo/icon.svg",
+ url: locationToCheck,
+ };
+ }
+ // Check for other video providers by URL pattern
+ const videoProviders = [
+ { pattern: /zoom\.us/, label: "Zoom", type: "zoom_video", appId: "zoom" },
+ {
+ pattern: /meet\.google\.com/,
+ label: "Google Meet",
+ type: "google_video",
+ appId: "google-meet",
+ },
+ {
+ pattern: /teams\.microsoft\.com/,
+ label: "Microsoft Teams",
+ type: "office365_video",
+ appId: "msteams",
+ },
+ ];
+
+ for (const provider of videoProviders) {
+ if (provider.pattern.test(locationToCheck)) {
+ const iconUrl = getAppIconUrl(provider.type, provider.appId);
+ return {
+ label: provider.label,
+ iconUrl: iconUrl,
+ url: locationToCheck,
+ };
+ }
+ }
+
+ // Generic link meeting
+ const linkIconUrl = getDefaultLocationIconUrl("link") || "https://app.cal.com/link.svg";
+ return {
+ label: "Link Meeting",
+ iconUrl: linkIconUrl,
+ url: locationToCheck,
+ };
+ }
+
+ // Check if it's an integration location (e.g., "integrations:zoom", "integrations:cal-video")
+ if (typeof locationToCheck === "string" && locationToCheck.startsWith("integrations:")) {
+ const appId = locationToCheck.replace("integrations:", "");
+ const iconUrl = getAppIconUrl("", appId);
+
+ if (iconUrl) {
+ return {
+ label: formatAppIdToDisplayName(appId),
+ iconUrl: iconUrl,
+ url: null,
+ };
+ }
+ }
+
+ // Check if it's a default location type
+ const defaultLocation = defaultLocations.find((loc) => loc.type === locationToCheck);
+ if (defaultLocation) {
+ return {
+ label: defaultLocation.label,
+ iconUrl: defaultLocation.iconUrl,
+ url: null,
+ };
+ }
+
+ // Fallback: return as plain text location
+ return {
+ label: locationToCheck as string,
+ iconUrl: null,
+ url: null,
+ };
+};
+
+export interface BookingDetailScreenProps {
+ uid: string;
+ onActionsReady?: (handlers: {
+ openRescheduleModal: () => void;
+ openEditLocationModal: () => void;
+ openAddGuestsModal: () => void;
+ openViewRecordingsModal: () => void;
+ openMeetingSessionDetailsModal: () => void;
+ openMarkNoShowModal: () => void;
+ handleCancelBooking: () => void;
+ }) => void;
+}
+
+export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreenProps) {
+ const router = useRouter();
+ const { userInfo } = useAuth();
+ const insets = useSafeAreaInsets();
+
+ const [loading, setLoading] = useState(true);
+ const [booking, setBooking] = useState(null);
+ const [error, setError] = useState(null);
+ const [isCancelling, setIsCancelling] = useState(false);
+ const [showCancelDialog, setShowCancelDialog] = useState(false);
+ const [cancellationReason, setCancellationReason] = useState("");
+
+ const contentInsets = {
+ top: insets.top,
+ bottom: insets.bottom,
+ left: 12,
+ right: 12,
+ };
+
+ // Compute actions using centralized gating
+ const actions = useMemo(() => {
+ if (!booking) return EMPTY_ACTIONS;
+ return getBookingActions({
+ booking,
+ eventType: undefined,
+ currentUserId: userInfo?.id,
+ currentUserEmail: userInfo?.email,
+ isOnline: true,
+ });
+ }, [booking, userInfo?.id, userInfo?.email]);
+
+ // Cancel booking handler
+ const performCancelBooking = useCallback(
+ async (reason: string) => {
+ if (!booking) return;
+
+ setIsCancelling(true);
+
+ try {
+ await CalComAPIService.cancelBooking(booking.uid, reason);
+ Alert.alert("Success", "Booking cancelled successfully", [
+ {
+ text: "OK",
+ onPress: () => router.back(),
+ },
+ ]);
+ setIsCancelling(false);
+ } catch (err) {
+ console.error("Failed to cancel booking");
+ if (__DEV__) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.debug("[BookingDetailScreen.android] cancelBooking failed", {
+ message,
+ });
+ }
+ showErrorAlert("Error", "Failed to cancel booking. Please try again.");
+ setIsCancelling(false);
+ }
+ },
+ [booking, router]
+ );
+
+ const handleCancelBooking = useCallback(() => {
+ if (!booking) return;
+ setCancellationReason("");
+ setShowCancelDialog(true);
+ }, [booking]);
+
+ const handleConfirmCancel = useCallback(() => {
+ const reason = cancellationReason.trim() || "Cancelled by host";
+ setShowCancelDialog(false);
+ setCancellationReason("");
+ performCancelBooking(reason);
+ }, [cancellationReason, performCancelBooking]);
+
+ const handleCloseCancelDialog = useCallback(() => {
+ setShowCancelDialog(false);
+ setCancellationReason("");
+ }, []);
+
+ // Navigate to reschedule screen
+ const openRescheduleModal = useCallback(() => {
+ if (!booking) return;
+ router.push({
+ pathname: "/reschedule",
+ params: { uid: booking.uid },
+ });
+ }, [booking, router]);
+
+ // Navigate to edit location screen
+ const openEditLocationModal = useCallback(() => {
+ if (!booking) return;
+ router.push({
+ pathname: "/edit-location",
+ params: { uid: booking.uid },
+ });
+ }, [booking, router]);
+
+ // Navigate to add guests screen
+ const openAddGuestsModal = useCallback(() => {
+ if (!booking) return;
+ router.push({
+ pathname: "/add-guests",
+ params: { uid: booking.uid },
+ });
+ }, [booking, router]);
+
+ // Navigate to mark no show screen
+ const openMarkNoShowModal = useCallback(() => {
+ if (!booking) return;
+ router.push({
+ pathname: "/mark-no-show",
+ params: { uid: booking.uid },
+ });
+ }, [booking, router]);
+
+ // Navigate to view recordings screen
+ const openViewRecordingsModal = useCallback(() => {
+ if (!booking) return;
+ router.push({
+ pathname: "/view-recordings",
+ params: { uid: booking.uid },
+ });
+ }, [booking, router]);
+
+ // Navigate to meeting session details screen
+ const openMeetingSessionDetailsModal = useCallback(() => {
+ if (!booking) return;
+ router.push({
+ pathname: "/meeting-session-details",
+ params: { uid: booking.uid },
+ });
+ }, [booking, router]);
+
+ const handleReportBooking = useCallback(() => {
+ Alert.alert("Report Booking", "Report booking functionality is not yet available");
+ }, []);
+
+ const fetchBooking = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ let bookingData: Booking | null = null;
+ let fetchError: Error | null = null;
+
+ try {
+ bookingData = await CalComAPIService.getBookingByUid(uid);
+ } catch (err) {
+ fetchError = err instanceof Error ? err : new Error(String(err));
+ }
+
+ if (bookingData) {
+ if (__DEV__) {
+ const hostCount = bookingData.hosts?.length ?? (bookingData.user ? 1 : 0);
+ const attendeeCount = bookingData.attendees?.length ?? 0;
+ console.debug("[BookingDetailScreen.android] booking fetched", {
+ uid: bookingData.uid,
+ status: bookingData.status,
+ hostCount,
+ attendeeCount,
+ hasRecurringEventId: Boolean(bookingData.recurringEventId),
+ });
+ }
+ setBooking(bookingData);
+ setLoading(false);
+ } else {
+ console.error("Error fetching booking");
+ if (__DEV__ && fetchError) {
+ console.debug("[BookingDetailScreen.android] fetchBooking failed", {
+ message: fetchError.message,
+ stack: fetchError.stack,
+ });
+ }
+ setError("Failed to load booking. Please try again.");
+ if (__DEV__) {
+ Alert.alert("Error", "Failed to load booking. Please try again.", [
+ { text: "OK", onPress: () => router.back() },
+ ]);
+ } else {
+ router.back();
+ }
+ setLoading(false);
+ }
+ }, [uid, router]);
+
+ useEffect(() => {
+ if (uid) {
+ fetchBooking();
+ } else {
+ setLoading(false);
+ setError("Invalid booking ID");
+ }
+ }, [uid, fetchBooking]);
+
+ // Expose action handlers to parent component
+ useEffect(() => {
+ if (booking && onActionsReady) {
+ onActionsReady({
+ openRescheduleModal,
+ openEditLocationModal,
+ openAddGuestsModal,
+ openViewRecordingsModal,
+ openMeetingSessionDetailsModal,
+ openMarkNoShowModal,
+ handleCancelBooking,
+ });
+ }
+ }, [
+ booking,
+ onActionsReady,
+ openRescheduleModal,
+ openEditLocationModal,
+ openAddGuestsModal,
+ openViewRecordingsModal,
+ openMeetingSessionDetailsModal,
+ handleCancelBooking,
+ openMarkNoShowModal,
+ ]);
+
+ const handleJoinMeeting = () => {
+ if (!booking?.location) return;
+
+ const provider = getLocationProvider(booking.location);
+ if (provider?.url) {
+ openInAppBrowser(provider.url, "meeting link");
+ }
+ };
+
+ // Build dropdown menu actions
+ const dropdownActions = useMemo(() => {
+ if (!booking) return [];
+
+ const _startTime = booking.start || booking.startTime || "";
+ const endTime = booking.end || booking.endTime || "";
+ const isPast = new Date(endTime) < new Date();
+ const isCancelled = booking.status.toLowerCase() === "cancelled";
+ const isPending = booking.status.toLowerCase() === "pending";
+ const isUpcoming = !isPast;
+
+ type DropdownAction = {
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ variant?: "default" | "destructive";
+ visible: boolean;
+ };
+
+ const allActions: DropdownAction[] = [
+ // Edit Event Section
+ {
+ label: "Reschedule Booking",
+ icon: "calendar-outline",
+ onPress: openRescheduleModal,
+ variant: "default",
+ visible: isUpcoming && !isCancelled && !isPending,
+ },
+ {
+ label: "Edit Location",
+ icon: "location-outline",
+ onPress: openEditLocationModal,
+ variant: "default",
+ visible: isUpcoming && !isCancelled && !isPending,
+ },
+ {
+ label: "Add Guests",
+ icon: "person-add-outline",
+ onPress: openAddGuestsModal,
+ variant: "default",
+ visible: isUpcoming && !isCancelled && !isPending,
+ },
+ // After Event Section
+ {
+ label: "View Recordings",
+ icon: "videocam-outline",
+ onPress: openViewRecordingsModal,
+ variant: "default",
+ visible: actions.viewRecordings.visible && actions.viewRecordings.enabled,
+ },
+ {
+ label: "Meeting Session Details",
+ icon: "information-circle-outline",
+ onPress: openMeetingSessionDetailsModal,
+ variant: "default",
+ visible: actions.meetingSessionDetails.visible && actions.meetingSessionDetails.enabled,
+ },
+ {
+ label: "Mark as No-Show",
+ icon: "eye-off-outline",
+ onPress: openMarkNoShowModal,
+ variant: "default",
+ visible: actions.markNoShow.visible && actions.markNoShow.enabled,
+ },
+ // Other Actions
+ {
+ label: "Report Booking",
+ icon: "flag-outline",
+ onPress: handleReportBooking,
+ variant: "destructive",
+ visible: true,
+ },
+ {
+ label: "Cancel Event",
+ icon: "close-circle-outline",
+ onPress: handleCancelBooking,
+ variant: "destructive",
+ visible: isUpcoming && !isCancelled,
+ },
+ ];
+
+ return allActions.filter((action) => action.visible);
+ }, [
+ booking,
+ actions,
+ openRescheduleModal,
+ openEditLocationModal,
+ openAddGuestsModal,
+ openViewRecordingsModal,
+ openMeetingSessionDetailsModal,
+ openMarkNoShowModal,
+ handleReportBooking,
+ handleCancelBooking,
+ ]);
+
+ // Find the index where destructive actions start
+ const destructiveStartIndex = dropdownActions.findIndex(
+ (action) => action.variant === "destructive"
+ );
+
+ if (loading) {
+ return (
+
+
+ Loading booking...
+
+ );
+ }
+
+ if (error || !booking) {
+ return (
+
+
+
+ {error || "Booking not found"}
+
+ router.back()}>
+ Go Back
+
+
+ );
+ }
+
+ const startTime = booking.start || booking.startTime || "";
+ const endTime = booking.end || booking.endTime || "";
+ const dateFormatted = formatDateFull(startTime);
+ const timeFormatted = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
+ const timezone = getTimezone();
+ const locationProvider = getLocationProvider(booking.location, booking.responses);
+
+ return (
+ <>
+ {/* Header with DropdownMenu */}
+ (
+
+
+
+
+
+
+
+
+ {dropdownActions.map((action, index) => (
+
+ {/* Add separator before destructive actions */}
+ {index === destructiveStartIndex && destructiveStartIndex > 0 && (
+
+ )}
+
+
+
+ {action.label}
+
+
+
+ ))}
+
+
+ ),
+ }}
+ />
+
+
+ {/* Title */}
+
+ {booking.title}
+
+ {dateFormatted} {timeFormatted} ({timezone})
+
+
+
+ {/* Who Section */}
+
+ Who
+ {/* Show host from user field or hosts array */}
+ {booking.user || (booking.hosts && booking.hosts.length > 0) ? (
+
+ {booking.user ? (
+
+
+
+ {getInitials(booking.user.name)}
+
+
+
+
+
+ {booking.user.name}
+
+
+ host
+
+
+ {booking.user.email}
+
+
+ ) : booking.hosts && booking.hosts.length > 0 ? (
+ booking.hosts.map((host, hostIndex) => (
+ 0 ? "mt-4" : ""}`}
+ >
+
+
+ {getInitials(host.name || "Host")}
+
+
+
+
+
+ {host.name || "Host"}
+
+
+ host
+
+
+ {host.email && {host.email}}
+
+
+ ))
+ ) : null}
+
+ ) : null}
+ {booking.attendees && booking.attendees.length > 0 ? (
+
+ {booking.attendees.map((attendee, index) => {
+ const isNoShow =
+ (attendee as { noShow?: boolean; absent?: boolean }).noShow === true ||
+ (attendee as { noShow?: boolean; absent?: boolean }).absent === true;
+ return (
+ 0 ? "mt-4" : ""}`}
+ >
+
+
+ {getInitials(attendee.name)}
+
+
+
+
+
+ {attendee.name}
+
+ {isNoShow && (
+
+
+
+ No-show
+
+
+ )}
+
+
+ {attendee.email}
+
+
+
+ );
+ })}
+
+ ) : null}
+
+
+ {/* Where Section */}
+ {locationProvider ? (
+
+ Where
+ {locationProvider.url ? (
+
+ {locationProvider.iconUrl ? (
+
+ ) : null}
+ {locationProvider.label}:
+
+ {locationProvider.url}
+
+
+ ) : (
+
+ {locationProvider.iconUrl ? (
+
+ ) : null}
+ {locationProvider.label}
+
+ )}
+
+ ) : null}
+
+ {/* Recurring Event Section */}
+ {booking.recurringEventId ||
+ (booking as { recurringBookingUid?: string }).recurringBookingUid ? (
+
+
+ This is part of a recurring event
+
+
+ ) : null}
+
+ {/* Description Section */}
+ {booking.description ? (
+
+ Description
+ {booking.description}
+
+ ) : null}
+
+ {/* Join Meeting Button */}
+ {locationProvider?.url ? (
+
+ {locationProvider.iconUrl ? (
+
+ ) : null}
+
+ Join {locationProvider.label}
+
+
+ ) : null}
+
+
+ {/* Cancelling overlay */}
+ {isCancelling && (
+
+
+
+
+ Cancelling booking...
+
+
+
+ )}
+
+
+ {/* Cancel Event AlertDialog */}
+
+
+
+
+ Cancel event
+
+
+
+ Cancellation reason will be shared with guests
+
+
+
+
+ {/* Reason Input */}
+
+ Reason for cancellation
+
+
+
+
+
+ Nevermind
+
+
+ Cancel event
+
+
+
+
+ >
+ );
+}
diff --git a/companion/components/screens/BookingDetailScreen.tsx b/companion/components/screens/BookingDetailScreen.tsx
index 8ec52ec1adf622..4c77990a86726d 100644
--- a/companion/components/screens/BookingDetailScreen.tsx
+++ b/companion/components/screens/BookingDetailScreen.tsx
@@ -52,8 +52,8 @@ const formatTime12Hour = (dateString: string): string => {
return `${hour12}:${minStr}${period}`;
};
-// Get timezone from date string
-const getTimezone = (_dateString: string): string => {
+// Get user's local timezone for display
+const getTimezone = (): string => {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return timeZone || "";
};
@@ -434,7 +434,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
const endTime = booking.end || booking.endTime || "";
const dateFormatted = formatDateFull(startTime);
const timeFormatted = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
- const timezone = getTimezone(startTime);
+ const timezone = getTimezone();
const locationProvider = getLocationProvider(booking.location, booking.responses);
return (
@@ -607,8 +607,9 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen
{booking.recurringEventId ||
(booking as { recurringBookingUid?: string }).recurringBookingUid ? (
- Recurring Event
- Every 2 weeks for 6 occurrences
+
+ This is part of a recurring event
+
) : null}
diff --git a/companion/components/ui/alert-dialog.tsx b/companion/components/ui/alert-dialog.tsx
new file mode 100644
index 00000000000000..2fa55c8a35a043
--- /dev/null
+++ b/companion/components/ui/alert-dialog.tsx
@@ -0,0 +1,156 @@
+import { buttonTextVariants, buttonVariants } from "@/components/ui/button";
+import { NativeOnlyAnimatedView } from "@/components/ui/native-only-animated-view";
+import { TextClassContext } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+import * as AlertDialogPrimitive from "@rn-primitives/alert-dialog";
+import * as React from "react";
+import { Platform, View, type ViewProps } from "react-native";
+import { FadeIn, FadeOut } from "react-native-reanimated";
+import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
+
+function AlertDialogOverlay({
+ className,
+ children,
+ ...props
+}: Omit &
+ React.RefAttributes & {
+ children?: React.ReactNode;
+ }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+function AlertDialogContent({
+ className,
+ portalHost,
+ ...props
+}: AlertDialogPrimitive.ContentProps &
+ React.RefAttributes & {
+ portalHost?: string;
+ }) {
+ return (
+
+
+
+
+
+ );
+}
+
+function AlertDialogHeader({ className, ...props }: ViewProps) {
+ return (
+
+
+
+ );
+}
+
+function AlertDialogFooter({ className, ...props }: ViewProps) {
+ return (
+
+ );
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: AlertDialogPrimitive.TitleProps & React.RefAttributes) {
+ return (
+
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: AlertDialogPrimitive.DescriptionProps &
+ React.RefAttributes) {
+ return (
+
+ );
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: AlertDialogPrimitive.ActionProps & React.RefAttributes) {
+ return (
+
+
+
+ );
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: AlertDialogPrimitive.CancelProps & React.RefAttributes) {
+ return (
+
+
+
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/companion/components/ui/alert.tsx b/companion/components/ui/alert.tsx
new file mode 100644
index 00000000000000..fe5e0334173620
--- /dev/null
+++ b/companion/components/ui/alert.tsx
@@ -0,0 +1,74 @@
+import type { LucideIcon } from "lucide-react-native";
+import * as React from "react";
+import { View, type ViewProps } from "react-native";
+import { Icon } from "@/components/ui/icon";
+import { Text, TextClassContext } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+function Alert({
+ className,
+ variant,
+ children,
+ icon,
+ iconClassName,
+ ...props
+}: ViewProps &
+ React.RefAttributes & {
+ icon: LucideIcon;
+ variant?: "default" | "destructive";
+ iconClassName?: string;
+ }) {
+ return (
+
+
+
+
+
+ {children}
+
+
+ );
+}
+
+function AlertTitle({
+ className,
+ ...props
+}: React.ComponentProps & React.RefAttributes) {
+ return (
+
+ );
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps & React.RefAttributes) {
+ const textClass = React.useContext(TextClassContext);
+ return (
+
+ );
+}
+
+export { Alert, AlertDescription, AlertTitle };
diff --git a/companion/components/ui/button.tsx b/companion/components/ui/button.tsx
new file mode 100644
index 00000000000000..ed13f6de1cad4f
--- /dev/null
+++ b/companion/components/ui/button.tsx
@@ -0,0 +1,108 @@
+import { TextClassContext } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Platform, Pressable } from "react-native";
+
+const buttonVariants = cva(
+ cn(
+ "group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none",
+ Platform.select({
+ web: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ })
+ ),
+ {
+ variants: {
+ variant: {
+ default: cn(
+ "bg-primary active:bg-primary/90 shadow-sm shadow-black/5",
+ Platform.select({ web: "hover:bg-primary/90" })
+ ),
+ destructive: cn(
+ "bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm shadow-black/5",
+ Platform.select({
+ web: "hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
+ })
+ ),
+ outline: cn(
+ "border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm shadow-black/5",
+ Platform.select({
+ web: "hover:bg-accent dark:hover:bg-input/50",
+ })
+ ),
+ secondary: cn(
+ "bg-secondary active:bg-secondary/80 shadow-sm shadow-black/5",
+ Platform.select({ web: "hover:bg-secondary/80" })
+ ),
+ ghost: cn(
+ "active:bg-accent dark:active:bg-accent/50",
+ Platform.select({ web: "hover:bg-accent dark:hover:bg-accent/50" })
+ ),
+ link: "",
+ },
+ size: {
+ default: cn("h-10 px-4 py-2 sm:h-9", Platform.select({ web: "has-[>svg]:px-3" })),
+ sm: cn("h-9 gap-1.5 rounded-md px-3 sm:h-8", Platform.select({ web: "has-[>svg]:px-2.5" })),
+ lg: cn("h-11 rounded-md px-6 sm:h-10", Platform.select({ web: "has-[>svg]:px-4" })),
+ icon: "h-10 w-10 sm:h-9 sm:w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+const buttonTextVariants = cva(
+ cn(
+ "text-foreground text-sm font-medium",
+ Platform.select({ web: "pointer-events-none transition-colors" })
+ ),
+ {
+ variants: {
+ variant: {
+ default: "text-primary-foreground",
+ destructive: "text-white",
+ outline: cn(
+ "group-active:text-accent-foreground",
+ Platform.select({ web: "group-hover:text-accent-foreground" })
+ ),
+ secondary: "text-secondary-foreground",
+ ghost: "group-active:text-accent-foreground",
+ link: cn(
+ "text-primary group-active:underline",
+ Platform.select({ web: "underline-offset-4 hover:underline group-hover:underline" })
+ ),
+ },
+ size: {
+ default: "",
+ sm: "",
+ lg: "",
+ icon: "",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+type ButtonProps = React.ComponentProps &
+ React.RefAttributes &
+ VariantProps;
+
+function Button({ className, variant, size, ...props }: ButtonProps) {
+ return (
+
+
+
+ );
+}
+
+export { Button, buttonTextVariants, buttonVariants };
+export type { ButtonProps };
diff --git a/companion/components/ui/dropdown-menu.tsx b/companion/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000000000..8b676c97150cb0
--- /dev/null
+++ b/companion/components/ui/dropdown-menu.tsx
@@ -0,0 +1,311 @@
+import * as DropdownMenuPrimitive from "@rn-primitives/dropdown-menu";
+import { Check, ChevronDown, ChevronRight, ChevronUp } from "lucide-react-native";
+import * as React from "react";
+import {
+ Platform,
+ type StyleProp,
+ StyleSheet,
+ Text,
+ type TextProps,
+ View,
+ type ViewStyle,
+} from "react-native";
+import { FadeIn } from "react-native-reanimated";
+import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
+import { Icon } from "@/components/ui/icon";
+import { NativeOnlyAnimatedView } from "@/components/ui/native-only-animated-view";
+import { TextClassContext } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ iconClassName,
+ ...props
+}: DropdownMenuPrimitive.SubTriggerProps &
+ React.RefAttributes & {
+ children?: React.ReactNode;
+ iconClassName?: string;
+ inset?: boolean;
+ }) {
+ const { open } = DropdownMenuPrimitive.useSubContext();
+ const icon = Platform.OS === "web" ? ChevronRight : open ? ChevronUp : ChevronDown;
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: DropdownMenuPrimitive.SubContentProps &
+ React.RefAttributes) {
+ return (
+
+
+
+ );
+}
+
+const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
+
+function DropdownMenuContent({
+ className,
+ overlayClassName,
+ overlayStyle,
+ portalHost,
+ ...props
+}: DropdownMenuPrimitive.ContentProps &
+ React.RefAttributes & {
+ overlayStyle?: StyleProp;
+ overlayClassName?: string;
+ portalHost?: string;
+ }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant,
+ ...props
+}: DropdownMenuPrimitive.ItemProps &
+ React.RefAttributes & {
+ className?: string;
+ inset?: boolean;
+ variant?: "default" | "destructive";
+ }) {
+ return (
+
+
+
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ ...props
+}: DropdownMenuPrimitive.CheckboxItemProps &
+ React.RefAttributes & {
+ children?: React.ReactNode;
+ }) {
+ return (
+
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: DropdownMenuPrimitive.RadioItemProps &
+ React.RefAttributes & {
+ children?: React.ReactNode;
+ }) {
+ return (
+
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: DropdownMenuPrimitive.LabelProps &
+ React.RefAttributes & {
+ className?: string;
+ inset?: boolean;
+ }) {
+ return (
+
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: DropdownMenuPrimitive.SeparatorProps & React.RefAttributes) {
+ return (
+
+ );
+}
+
+function DropdownMenuShortcut({ className, ...props }: TextProps & React.RefAttributes) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+};
diff --git a/companion/components/ui/icon.tsx b/companion/components/ui/icon.tsx
new file mode 100644
index 00000000000000..810575878e86d9
--- /dev/null
+++ b/companion/components/ui/icon.tsx
@@ -0,0 +1,54 @@
+import type { LucideIcon, LucideProps } from "lucide-react-native";
+import { cssInterop } from "nativewind";
+import { cn } from "@/lib/utils";
+
+type IconProps = LucideProps & {
+ as: LucideIcon;
+};
+
+function IconImpl({ as: IconComponent, ...props }: IconProps) {
+ return ;
+}
+
+cssInterop(IconImpl, {
+ className: {
+ target: "style",
+ nativeStyleToProp: {
+ height: "size",
+ width: "size",
+ },
+ },
+});
+
+/**
+ * A wrapper component for Lucide icons with Nativewind `className` support via `cssInterop`.
+ *
+ * This component allows you to render any Lucide icon while applying utility classes
+ * using `nativewind`. It avoids the need to wrap or configure each icon individually.
+ *
+ * @component
+ * @example
+ * ```tsx
+ * import { ArrowRight } from 'lucide-react-native';
+ * import { Icon } from '@/components/ui/icon';
+ *
+ *
+ * ```
+ *
+ * @param {LucideIcon} as - The Lucide icon component to render.
+ * @param {string} className - Utility classes to style the icon using Nativewind.
+ * @param {number} size - Icon size (defaults to 14).
+ * @param {...LucideProps} ...props - Additional Lucide icon props passed to the "as" icon.
+ */
+function Icon({ as: IconComponent, className, size = 14, ...props }: IconProps) {
+ return (
+
+ );
+}
+
+export { Icon };
diff --git a/companion/components/ui/native-only-animated-view.tsx b/companion/components/ui/native-only-animated-view.tsx
new file mode 100644
index 00000000000000..7a1a471d08b721
--- /dev/null
+++ b/companion/components/ui/native-only-animated-view.tsx
@@ -0,0 +1,23 @@
+import { Platform } from "react-native";
+import Animated from "react-native-reanimated";
+
+/**
+ * This component is used to wrap animated views that should only be animated on native.
+ * @param props - The props for the animated view.
+ * @returns The animated view if the platform is native, otherwise the children.
+ * @example
+ *
+ * I am only animated on native
+ *
+ */
+function NativeOnlyAnimatedView(
+ props: React.ComponentProps & React.RefAttributes
+) {
+ if (Platform.OS === "web") {
+ return <>{props.children as React.ReactNode}>;
+ } else {
+ return ;
+ }
+}
+
+export { NativeOnlyAnimatedView };
diff --git a/companion/components/ui/text.tsx b/companion/components/ui/text.tsx
new file mode 100644
index 00000000000000..b604a9d846e368
--- /dev/null
+++ b/companion/components/ui/text.tsx
@@ -0,0 +1,89 @@
+import { cn } from "@/lib/utils";
+import * as Slot from "@rn-primitives/slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+import { Platform, Text as RNText, type Role } from "react-native";
+
+const textVariants = cva(
+ cn(
+ "text-foreground text-base",
+ Platform.select({
+ web: "select-text",
+ })
+ ),
+ {
+ variants: {
+ variant: {
+ default: "",
+ h1: cn(
+ "text-center text-4xl font-extrabold tracking-tight",
+ Platform.select({ web: "scroll-m-20 text-balance" })
+ ),
+ h2: cn(
+ "border-border border-b pb-2 text-3xl font-semibold tracking-tight",
+ Platform.select({ web: "scroll-m-20 first:mt-0" })
+ ),
+ h3: cn("text-2xl font-semibold tracking-tight", Platform.select({ web: "scroll-m-20" })),
+ h4: cn("text-xl font-semibold tracking-tight", Platform.select({ web: "scroll-m-20" })),
+ p: "mt-3 leading-7 sm:mt-6",
+ blockquote: "mt-4 border-l-2 pl-3 italic sm:mt-6 sm:pl-6",
+ code: cn(
+ "bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold"
+ ),
+ lead: "text-muted-foreground text-xl",
+ large: "text-lg font-semibold",
+ small: "text-sm font-medium leading-none",
+ muted: "text-muted-foreground text-sm",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+type TextVariantProps = VariantProps;
+
+type TextVariant = NonNullable;
+
+const ROLE: Partial> = {
+ h1: "heading",
+ h2: "heading",
+ h3: "heading",
+ h4: "heading",
+ blockquote: Platform.select({ web: "blockquote" as Role }),
+ code: Platform.select({ web: "code" as Role }),
+};
+
+const ARIA_LEVEL: Partial> = {
+ h1: "1",
+ h2: "2",
+ h3: "3",
+ h4: "4",
+};
+
+const TextClassContext = React.createContext(undefined);
+
+function Text({
+ className,
+ asChild = false,
+ variant = "default",
+ ...props
+}: React.ComponentProps &
+ TextVariantProps &
+ React.RefAttributes & {
+ asChild?: boolean;
+ }) {
+ const textClass = React.useContext(TextClassContext);
+ const Component = asChild ? Slot.Text : RNText;
+ return (
+
+ );
+}
+
+export { Text, TextClassContext };
diff --git a/companion/components/ui/toast.tsx b/companion/components/ui/toast.tsx
new file mode 100644
index 00000000000000..148bd241f98cd5
--- /dev/null
+++ b/companion/components/ui/toast.tsx
@@ -0,0 +1,59 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+interface ToastProps {
+ visible: boolean;
+ message: string;
+ type: "success" | "error";
+}
+
+/**
+ * Toast notification component that displays at the bottom of the screen.
+ * Use with the useToast hook for state management.
+ *
+ * @example
+ * ```tsx
+ * const { toast, showToast } = useToast();
+ *
+ * return (
+ * <>
+ *