From 49ae10149f0e56f7fcbc62d654cf2c751f03f8ae Mon Sep 17 00:00:00 2001 From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Date: Mon, 29 Dec 2025 03:18:48 +0530 Subject: [PATCH] feat(companion): iOS native UI components and simplified event type creation (#26240) ios: https://github.com/user-attachments/assets/efdfd3e3-239f-4cbf-be1a-7784d1df1851 android: https://github.com/user-attachments/assets/a8e22a5c-60f3-4fb6-8923-b8a786bceb0c ## Overview This PR introduces native iOS UI components, refactors the profile menu, and simplifies the event type creation flow for a more native iOS experience. ## Key Changes ### iOS Native UI Components - **iOS-specific Event Types Page**: Added native Stack.Header, glass UI, and context menus for iOS - **Platform-specific Profile Sheet**: Created separate implementations for iOS (formSheet) and Android/Web (modal) - **Bottom Glass UI Navbar**: Updated bottom navigation bar with glass UI styling for iOS - **Native iOS Alerts**: Replaced custom modals with native `Alert.prompt` for event types and availability pages ### Profile Menu Refactor - Restructured More page with folder layout and native iOS header support - Added profile button to More page header on iOS with glass UI - Added "Copy public page link" and "Roadmap" options to profile sheet - Refactored Header component: removed inline profile modal, now uses route-based navigation - Updated Availability page with New menu and profile button in header - Fixed "My Settings" URL to use `/general` endpoint - Removed "Profile" item from More section menu - Updated tab layout to support `(more)` folder structure ### Simplified Event Type Creation - Reduced event type creation to only require title input - Auto-generate slug from title using slugify utility - Set default duration to 15 minutes - Leave description empty (users can edit later) - Applied to both iOS and Android/Web platforms ### UI Improvements - Updated login button styling: changed background color to pure black (#000000) with white text for better contrast --- companion/app.json | 6 +- companion/app/(tabs)/(availability)/index.tsx | 91 ++- companion/app/(tabs)/(bookings)/index.tsx | 3 +- .../app/(tabs)/(bookings)/view-recordings.tsx | 3 +- .../app/(tabs)/(event-types)/index.ios.tsx | 560 ++++++++++++++++++ companion/app/(tabs)/(event-types)/index.tsx | 97 +-- companion/app/(tabs)/(more)/_layout.tsx | 17 + companion/app/(tabs)/(more)/index.ios.tsx | 162 +++++ .../app/(tabs)/{more.tsx => (more)/index.tsx} | 9 +- companion/app/(tabs)/_layout.tsx | 14 +- companion/app/_layout.tsx | 28 + companion/app/event-type-detail.tsx | 22 +- companion/app/oauth/callback.tsx | 5 +- companion/app/profile-sheet.ios.tsx | 213 +++++++ companion/app/profile-sheet.tsx | 195 ++++++ companion/components/Header.tsx | 325 ++-------- companion/components/LoginScreen.tsx | 2 +- .../components/screens/AddGuestsScreen.tsx | 4 +- .../screens/EditLocationScreen.ios.tsx | 5 +- .../components/screens/EditLocationScreen.tsx | 5 +- .../components/screens/RescheduleScreen.tsx | 12 +- .../extension/entrypoints/background/index.ts | 178 +++++- companion/extension/entrypoints/content.ts | 164 ++--- companion/services/calcom.ts | 156 ++++- companion/services/oauthService.ts | 29 +- companion/services/webAuth.ts | 52 +- companion/utils/bookings-utils.ts | 10 +- companion/utils/index.ts | 4 +- companion/utils/network.ts | 42 ++ companion/utils/queryPersister.ts | 13 +- companion/utils/safeLogger.ts | 72 +++ 31 files changed, 1912 insertions(+), 586 deletions(-) create mode 100644 companion/app/(tabs)/(event-types)/index.ios.tsx create mode 100644 companion/app/(tabs)/(more)/_layout.tsx create mode 100644 companion/app/(tabs)/(more)/index.ios.tsx rename companion/app/(tabs)/{more.tsx => (more)/index.tsx} (95%) create mode 100644 companion/app/profile-sheet.ios.tsx create mode 100644 companion/app/profile-sheet.tsx create mode 100644 companion/utils/safeLogger.ts diff --git a/companion/app.json b/companion/app.json index 476ca44096..07beee1266 100644 --- a/companion/app.json +++ b/companion/app.json @@ -19,6 +19,9 @@ "bundleIdentifier": "com.calcom.companion", "infoPlist": { "ITSAppUsesNonExemptEncryption": false + }, + "entitlements": { + "com.apple.developer.default-data-protection": "NSFileProtectionComplete" } }, "android": { @@ -28,7 +31,8 @@ }, "edgeToEdgeEnabled": true, "package": "com.calcom.companion", - "softwareKeyboardLayoutMode": "pan" + "softwareKeyboardLayoutMode": "pan", + "usesCleartextTraffic": false }, "web": { "favicon": "./assets/favicon.png", diff --git a/companion/app/(tabs)/(availability)/index.tsx b/companion/app/(tabs)/(availability)/index.tsx index 154c747526..8a4e401b84 100644 --- a/companion/app/(tabs)/(availability)/index.tsx +++ b/companion/app/(tabs)/(availability)/index.tsx @@ -1,15 +1,86 @@ import { isLiquidGlassAvailable } from "expo-glass-effect"; -import { Stack } from "expo-router"; +import { Stack, useRouter } from "expo-router"; import { useState } from "react"; -import { Platform } from "react-native"; +import { Alert, Platform } from "react-native"; import { AvailabilityListScreen } from "@/components/screens/AvailabilityListScreen"; +import { useCreateSchedule } from "@/hooks"; +import { CalComAPIService } from "@/services/calcom"; +import { showErrorAlert } from "@/utils/alerts"; export default function Availability() { + const router = useRouter(); const [searchQuery, setSearchQuery] = useState(""); const [showCreateModal, setShowCreateModal] = useState(false); + const { mutate: createScheduleMutation } = useCreateSchedule(); const handleCreateNew = () => { - setShowCreateModal(true); + // Use native iOS Alert.prompt for a native look + Alert.prompt( + "Add a new schedule", + "Create a new availability schedule.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Continue", + onPress: async (name?: string) => { + if (!name?.trim()) { + Alert.alert("Error", "Please enter a schedule name"); + return; + } + + // Get user's timezone (default to America/New_York if not available) + let userTimezone = "America/New_York"; + try { + const userProfile = await CalComAPIService.getUserProfile(); + if (userProfile.timeZone) { + userTimezone = userProfile.timeZone; + } + } catch { + console.log("Could not get user timezone, using default"); + } + + // Create schedule with Monday-Friday 9 AM - 5 PM default + createScheduleMutation( + { + name: name.trim(), + timeZone: userTimezone, + isDefault: false, + availability: [ + { + days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + startTime: "09:00", + endTime: "17:00", + }, + ], + }, + { + onSuccess: (newSchedule) => { + // Navigate to edit the newly created schedule + router.push({ + pathname: "/availability-detail", + params: { + id: newSchedule.id.toString(), + }, + }); + }, + onError: (error) => { + const message = error instanceof Error ? error.message : String(error); + console.error("Failed to create schedule", message); + if (__DEV__) { + const stack = error instanceof Error ? error.stack : undefined; + console.debug("[Availability] createSchedule failed", { message, stack }); + } + showErrorAlert("Error", "Failed to create schedule. Please try again."); + }, + } + ); + }, + }, + ], + "plain-text", + "", + "default" + ); }; return ( @@ -21,8 +92,18 @@ export default function Availability() { > Availability - - New + {/* New Menu */} + + + + + New Availability + + + + {/* Profile Button */} + router.push("/profile-sheet")}> + (); @@ -17,7 +18,7 @@ export default function ViewRecordings() { CalComAPIService.getRecordings(uid) .then(setRecordings) .catch((error) => { - console.error("Failed to load recordings:", error); + safeLogError("Failed to load recordings:", error); Alert.alert("Error", "Failed to load recordings"); router.back(); }) diff --git a/companion/app/(tabs)/(event-types)/index.ios.tsx b/companion/app/(tabs)/(event-types)/index.ios.tsx new file mode 100644 index 0000000000..40d9f4f573 --- /dev/null +++ b/companion/app/(tabs)/(event-types)/index.ios.tsx @@ -0,0 +1,560 @@ +import { Button, ContextMenu, Host, HStack, Image as SwiftUIImage } from "@expo/ui/swift-ui"; +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 { Stack, useRouter } from "expo-router"; +import { useMemo, useState } from "react"; +import { + ActionSheetIOS, + Alert, + RefreshControl, + ScrollView, + Share, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { EmptyScreen } from "@/components/EmptyScreen"; +import { EventTypeListItem } from "@/components/event-type-list-item/EventTypeListItem"; +import { FullScreenModal } from "@/components/FullScreenModal"; +import { LoadingSpinner } from "@/components/LoadingSpinner"; +import { + useCreateEventType, + useDeleteEventType, + useDuplicateEventType, + useEventTypes, +} from "@/hooks"; +import { CalComAPIService, type EventType } from "@/services/calcom"; +import { showErrorAlert } from "@/utils/alerts"; +import { openInAppBrowser } from "@/utils/browser"; +import { getEventDuration } from "@/utils/getEventDuration"; +import { offlineAwareRefresh } from "@/utils/network"; +import { normalizeMarkdown } from "@/utils/normalizeMarkdown"; +import { slugify } from "@/utils/slugify"; + +export default function EventTypesIOS() { + const router = useRouter(); + const [searchQuery, setSearchQuery] = useState(""); + + // No modal state needed for iOS - using native Alert.prompt + + // Use React Query hooks + const { + data: eventTypes = [], + isLoading: loading, + isFetching, + error: queryError, + refetch, + } = useEventTypes(); + + // Show refresh indicator when fetching + const refreshing = isFetching && !loading; + + const { mutate: createEventTypeMutation } = useCreateEventType(); + const { mutate: deleteEventTypeMutation, isPending: isDeleting } = useDeleteEventType(); + const { mutate: duplicateEventTypeMutation } = useDuplicateEventType(); + + // Convert query error to string + // Don't show error UI for authentication errors (user will be redirected to login) + // Only show error UI in development mode for other errors + const isAuthError = + queryError?.message?.includes("Authentication") || + queryError?.message?.includes("sign in") || + queryError?.message?.includes("401"); + const error = queryError && !isAuthError && __DEV__ ? "Failed to load event types." : null; + + // Modal state for delete confirmation + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [eventTypeToDelete, setEventTypeToDelete] = useState(null); + + // Handle pull-to-refresh (offline-aware) + const onRefresh = () => offlineAwareRefresh(refetch); + + // Filter event types based on search query + const filteredEventTypes = useMemo(() => { + if (searchQuery.trim() === "") { + return eventTypes; + } + const searchLower = searchQuery.toLowerCase(); + return eventTypes.filter( + (eventType) => + eventType.title.toLowerCase().includes(searchLower) || + eventType.description?.toLowerCase().includes(searchLower) + ); + }, [eventTypes, searchQuery]); + + const handleSearch = (query: string) => { + setSearchQuery(query); + }; + + const handleEventTypePress = (eventType: EventType) => { + handleEdit(eventType); + }; + + const handleEventTypeLongPress = (eventType: EventType) => { + ActionSheetIOS.showActionSheetWithOptions( + { + options: ["Cancel", "Edit", "Duplicate", "Delete"], + destructiveButtonIndex: 3, // Delete button + cancelButtonIndex: 0, + title: eventType.title, + message: eventType.description ? normalizeMarkdown(eventType.description) : undefined, + }, + (buttonIndex) => { + switch (buttonIndex) { + case 1: // Edit + handleEdit(eventType); + break; + case 2: // Duplicate + handleDuplicate(eventType); + break; + case 3: // Delete + handleDelete(eventType); + break; + default: + // Cancel - do nothing + break; + } + } + ); + }; + + const handleCopyLink = async (eventType: EventType) => { + try { + const link = await CalComAPIService.buildEventTypeLink(eventType.slug); + await Clipboard.setStringAsync(link); + Alert.alert("Link Copied", "Event type link copied!"); + } catch { + showErrorAlert("Error", "Failed to copy link. Please try again."); + } + }; + + const _handleShare = async (eventType: EventType) => { + try { + const link = await CalComAPIService.buildEventTypeLink(eventType.slug); + await Share.share({ + message: `Book a meeting: ${eventType.title}`, + url: link, + }); + } catch { + showErrorAlert("Error", "Failed to share link. Please try again."); + } + }; + + const handleEdit = (eventType: EventType) => { + const duration = getEventDuration(eventType); + router.push({ + pathname: "/event-type-detail", + params: { + id: eventType.id.toString(), + title: eventType.title, + description: eventType.description || "", + duration: duration.toString(), + price: eventType.price?.toString() || "", + currency: eventType.currency || "", + slug: eventType.slug || "", + }, + }); + }; + + const handleDelete = (eventType: EventType) => { + setEventTypeToDelete(eventType); + setShowDeleteModal(true); + }; + + const confirmDelete = () => { + if (!eventTypeToDelete) return; + + deleteEventTypeMutation(eventTypeToDelete.id, { + onSuccess: () => { + // Close modal and reset state + setShowDeleteModal(false); + setEventTypeToDelete(null); + Alert.alert("Success", "Event type deleted successfully"); + }, + onError: (deleteError) => { + const message = deleteError instanceof Error ? deleteError.message : String(deleteError); + console.error("Failed to delete event type", message); + if (__DEV__) { + const stack = deleteError instanceof Error ? deleteError.stack : undefined; + console.debug("[EventTypes] deleteEventType failed", { message, stack }); + } + showErrorAlert("Error", "Failed to delete event type. Please try again."); + }, + }); + }; + + const handleDuplicate = (eventType: EventType) => { + duplicateEventTypeMutation( + { eventType, existingEventTypes: eventTypes }, + { + onSuccess: (duplicatedEventType) => { + Alert.alert("Success", "Event type duplicated successfully"); + + const duration = getEventDuration(eventType); + + // Navigate to edit the newly created duplicate + router.push({ + pathname: "/event-type-detail", + params: { + id: duplicatedEventType.id.toString(), + title: duplicatedEventType.title, + description: duplicatedEventType.description || "", + duration: ( + duplicatedEventType.lengthInMinutes || + duplicatedEventType.length || + duration + ).toString(), + slug: duplicatedEventType.slug || "", + }, + }); + }, + onError: (duplicateError) => { + const message = + duplicateError instanceof Error ? duplicateError.message : String(duplicateError); + console.error("Failed to duplicate event type", message); + if (__DEV__) { + const stack = duplicateError instanceof Error ? duplicateError.stack : undefined; + console.debug("[EventTypes] duplicateEventType failed", { message, stack }); + } + showErrorAlert("Error", "Failed to duplicate event type. Please try again."); + }, + } + ); + }; + + const handlePreview = async (eventType: EventType) => { + try { + const link = await CalComAPIService.buildEventTypeLink(eventType.slug); + // For mobile, use in-app browser + await openInAppBrowser(link, "event type preview"); + } catch { + console.error("Failed to open preview"); + showErrorAlert("Error", "Failed to open preview. Please try again."); + } + }; + + const handleOpenCreateModal = () => { + // Use native iOS Alert.prompt for a native look + Alert.prompt( + "Add a new event type", + "Set up event types to offer different types of meetings.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Continue", + onPress: (title?: string) => { + if (!title?.trim()) { + Alert.alert("Error", "Please enter a title for your event type"); + return; + } + + const autoSlug = slugify(title.trim()); + if (!autoSlug) { + Alert.alert("Error", "Title must contain at least one letter or number"); + return; + } + + createEventTypeMutation( + { + title: title.trim(), + slug: autoSlug, + lengthInMinutes: 15, // Default duration + description: undefined, // Empty description + }, + { + onSuccess: (newEventType) => { + // Navigate to edit the newly created event type + router.push({ + pathname: "/event-type-detail", + params: { + id: newEventType.id.toString(), + title: newEventType.title, + description: newEventType.description || "", + duration: ( + newEventType.lengthInMinutes || + newEventType.length || + 15 + ).toString(), + slug: newEventType.slug || "", + }, + }); + }, + onError: (createError) => { + const message = + createError instanceof Error ? createError.message : String(createError); + console.error("Failed to create event type", message); + if (__DEV__) { + const stack = createError instanceof Error ? createError.stack : undefined; + console.debug("[EventTypes] createEventType failed", { message, stack }); + } + showErrorAlert("Error", "Failed to create event type. Please try again."); + }, + } + ); + }, + }, + ], + "plain-text", + "", + "default" + ); + }; + + // Sort by menu handler (dummy for now) + const handleSortByOption = (option: string) => { + console.log("Sort by:", option); + // TODO: Implement actual sorting logic + }; + + // Filter menu handler (dummy for now) + const handleFilterOption = (option: string) => { + console.log("Filter by:", option); + // TODO: Implement actual filtering logic + }; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + + Unable to load event types + + {error} + refetch()}> + Retry + + + ); + } + + if (eventTypes.length === 0) { + return ( + <> + + + + + + ); + } + + return ( + <> + {/* iOS Native Header with Glass UI */} + + Event Types + + + {/* Filter/Sort Menu */} + + + + {/* Sort by Submenu - opens as separate submenu */} + + handleSortByOption("alphabetical")} + > + Alphabetical + + handleSortByOption("newest")} + > + Newest First + + handleSortByOption("duration")}> + By Duration + + + + {/* Filter Submenu - opens as separate submenu */} + + handleFilterOption("all")} + > + All Event Types + + handleFilterOption("active")}> + Active Only + + handleFilterOption("paid")} + > + Paid Events + + + + + {/* Profile Button - Opens bottom sheet */} + router.push("/profile-sheet")}> + + + + + {/* Search Bar */} + handleSearch(e.nativeEvent.text)} + obscureBackground={false} + barTintColor="#fff" + /> + + + {/* Event Types List */} + } + showsVerticalScrollIndicator={false} + contentInsetAdjustmentBehavior="automatic" + > + {filteredEventTypes.length === 0 && searchQuery.trim() !== "" ? ( + + + + ) : ( + + + {filteredEventTypes.map((item, index) => ( + + ))} + + + )} + + + {/* Floating Action Button for New Event Type with Glass UI Menu */} + + + + +