feat(companion): update Android booking detail page to match iOS design (#26715)

* feat(companion): update Android booking detail page to match iOS design

* feat(companion): add video icon button to Android header for join meeting

* fix(companion): use eventType.description instead of booking.description

Address Cubic AI review feedback - the booking description may contain
Additional Notes which can cause duplicate display. The event type
description is the proper source for this information.

Co-Authored-By: unknown <>

* Revert "fix(companion): use eventType.description instead of booking.description"

This reverts commit 3e324313f2d72704ab51f723a52b2784b33e7867.

* fix(companion): extract IOSPickerTrigger to platform-specific files for web compatibility

* fix(companion): extract iOS pickers to platform-specific files for web compatibility

Extract iOS-only native picker components from AvailabilityTab, BasicsTab,
LimitsTab, RecurringTab, and LocationsList to platform-specific files to
fix 'requireNativeViewManager is not available on web' error in browser extension.

Each tab now has:
- TabIOSPicker.ios.tsx - iOS implementation with native @expo/ui/swift-ui
- TabIOSPicker.tsx - Web/Android fallback that returns null

* fix(companion): fix Switch toggle thumb color for web/extension

Change thumbColor condition from Platform.OS === 'android' to
Platform.OS !== 'ios' so that web/extension also gets white thumb color
instead of the default green.

* fix(companion): fix Participants section padding for web/extension

Move padding classes from AppPressable to a child View to ensure
padding is properly applied on web. AnimatedPressable doesn't
reliably apply className padding on web platforms.

* fix(companion): fix unexpected text node error on web

Change ternary operator in Hosts section from 'booking.user && (...)' to
'booking.user ? (...) : null' to prevent React Native Web from trying
to render 'false' as a text node when booking.user is falsy.

* fix(companion): wrap Stack.Header in iOS platform check for web compatibility

The Stack.Header components are iOS-specific and cause 'Unexpected text node'
errors on web. Wrap them in Platform.OS === 'ios' check to prevent rendering
on web/Android where they're not supported.

* fix(companion): convert all && conditionals to ternary operators for web compatibility

React Native Web renders boolean 'false' from && operators as text nodes.
Changed all conditional rendering patterns from 'condition && (<Component>)'
to 'condition ? (<Component>) : null' to prevent 'Unexpected text node' errors.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Dhairyashil Shinde
2026-01-11 16:02:13 -03:00
committed by GitHub
co-authored by Devin AI
parent f0ffc24d39
commit 478ca05246
20 changed files with 698 additions and 641 deletions
@@ -1,5 +1,6 @@
import { Stack, useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react";
import { Platform } from "react-native";
import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen";
import { useAuth } from "@/contexts/AuthContext";
import { useBookingByUid } from "@/hooks/useBookings";
@@ -225,57 +226,59 @@ export default function BookingDetail() {
}}
/>
<Stack.Header style={{ shadowColor: "transparent" }}>
<Stack.Header.Title>Booking Details</Stack.Header.Title>
{Platform.OS === "ios" && (
<Stack.Header style={{ shadowColor: "transparent" }}>
<Stack.Header.Title>Booking Details</Stack.Header.Title>
{/* Header right and left API only works on iOS ATM see: https://docs.expo.dev/versions/unversioned/sdk/router/#stackheaderright */}
<Stack.Header.Right>
<Stack.Header.Menu>
<Stack.Header.Label>Actions</Stack.Header.Label>
<Stack.Header.Icon sf="ellipsis" />
{/* Header right and left API only works on iOS ATM see: https://docs.expo.dev/versions/unversioned/sdk/router/#stackheaderright */}
<Stack.Header.Right>
<Stack.Header.Menu>
<Stack.Header.Label>Actions</Stack.Header.Label>
<Stack.Header.Icon sf="ellipsis" />
{/* Edit Event Section */}
<Stack.Header.Menu inline title="Edit Event">
{bookingActionsSections.editEvent.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
>
{action.label}
</Stack.Header.MenuAction>
))}
{/* Edit Event Section */}
<Stack.Header.Menu inline title="Edit Event">
{bookingActionsSections.editEvent.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
{/* After Event Section */}
<Stack.Header.Menu inline title="After Event">
{bookingActionsSections.afterEvent.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
{/* Danger Zone Submenu */}
<Stack.Header.Menu inline title="Danger Zone">
{bookingActionsSections.standalone.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
destructive
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
</Stack.Header.Menu>
{/* After Event Section */}
<Stack.Header.Menu inline title="After Event">
{bookingActionsSections.afterEvent.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
{/* Danger Zone Submenu */}
<Stack.Header.Menu inline title="Danger Zone">
{bookingActionsSections.standalone.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
destructive
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
</Stack.Header.Menu>
</Stack.Header.Right>
</Stack.Header>
</Stack.Header.Right>
</Stack.Header>
)}
<BookingDetailScreen
booking={booking}
+11 -39
View File
@@ -3,10 +3,7 @@
* Reusable component for displaying and managing multiple event type locations
*/
import { Button, ContextMenu, Host } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import type React from "react";
import { useState } from "react";
import { Modal, Platform, ScrollView, Text, TextInput, TouchableOpacity, View } from "react-native";
@@ -17,6 +14,7 @@ import {
getLocationInputPlaceholder,
locationRequiresInput,
} from "@/utils/locationHelpers";
import { LocationsListIOSPicker } from "./LocationsListIOSPicker";
import { SvgImage } from "./SvgImage";
interface LocationsListProps {
@@ -54,19 +52,6 @@ function isLocationAlreadyAdded(locations: LocationItem[], optionValue: string):
});
}
const getSFSymbolForType = (type: string) => {
switch (type) {
case "address":
return "mappin.and.ellipse";
case "link":
return "link";
case "phone":
return "phone";
default:
return "video";
}
};
export const AddLocationTrigger = ({
isHeader = false,
locationOptions,
@@ -116,30 +101,17 @@ export const AddLocationTrigger = ({
return trigger;
}
return (
<Host matchContents>
<ContextMenu
modifiers={!isHeader ? [buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")] : []}
activationMethod="singlePress"
>
<ContextMenu.Items>
{locationOptions.flatMap((group) =>
group.options
.filter((opt) => !isLocationAlreadyAdded(locations, opt.value))
.map((option) => (
<Button
key={option.value}
onPress={() => onSelectOption(option.value, option.label)}
label={option.label}
systemImage={getSFSymbolForType(option.value)}
/>
))
)}
</ContextMenu.Items>
<ContextMenu.Trigger>{trigger}</ContextMenu.Trigger>
</ContextMenu>
</Host>
const iosPicker = (
<LocationsListIOSPicker
isHeader={isHeader}
locationOptions={locationOptions}
locations={locations}
onSelectOption={onSelectOption}
trigger={trigger}
/>
);
return iosPicker ?? trigger;
};
export const LocationsList: React.FC<LocationsListProps> = ({
@@ -0,0 +1,71 @@
import { Button, ContextMenu, Host } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import type React from "react";
import type { LocationItem, LocationOptionGroup } from "@/types/locations";
const getSFSymbolForType = (type: string) => {
switch (type) {
case "address":
return "mappin.and.ellipse";
case "link":
return "link";
case "phone":
return "phone";
default:
return "video";
}
};
function isLocationAlreadyAdded(locations: LocationItem[], optionValue: string): boolean {
return locations.some((loc) => {
if (loc.type === "integration" && optionValue.startsWith("integrations:")) {
return loc.integration === optionValue.replace("integrations:", "");
}
if (["address", "link", "phone"].includes(loc.type)) {
return false;
}
return loc.type === optionValue;
});
}
interface LocationsListIOSPickerProps {
isHeader?: boolean;
locationOptions: LocationOptionGroup[];
locations: LocationItem[];
onSelectOption: (value: string, label: string) => void;
trigger: React.ReactNode;
}
export function LocationsListIOSPicker({
isHeader = false,
locationOptions,
locations,
onSelectOption,
trigger,
}: LocationsListIOSPickerProps) {
return (
<Host matchContents>
<ContextMenu
modifiers={!isHeader ? [buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")] : []}
activationMethod="singlePress"
>
<ContextMenu.Items>
{locationOptions.flatMap((group) =>
group.options
.filter((opt) => !isLocationAlreadyAdded(locations, opt.value))
.map((option) => (
<Button
key={option.value}
onPress={() => onSelectOption(option.value, option.label)}
label={option.label}
systemImage={getSFSymbolForType(option.value)}
/>
))
)}
</ContextMenu.Items>
<ContextMenu.Trigger>{trigger}</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
}
@@ -0,0 +1,14 @@
import type React from "react";
import type { LocationItem, LocationOptionGroup } from "@/types/locations";
interface LocationsListIOSPickerProps {
isHeader?: boolean;
locationOptions: LocationOptionGroup[];
locations: LocationItem[];
onSelectOption: (value: string, label: string) => void;
trigger: React.ReactNode;
}
export function LocationsListIOSPicker(_props: LocationsListIOSPickerProps) {
return null;
}
@@ -5,10 +5,7 @@
* Uses native iOS pickers via ContextMenu and grouped rows for consistency.
*/
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { useState } from "react";
import {
Alert,
@@ -24,6 +21,7 @@ import {
import { showInfoAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { IOSPickerTrigger } from "./IOSPickerTrigger";
// Interface language options matching API V2 enum
const interfaceLanguageOptions = [
@@ -161,7 +159,7 @@ function SettingRow({
value={value}
onValueChange={onValueChange}
trackColor={{ false: "#E9E9EA", true: "#000000" }}
thumbColor={Platform.OS === "android" ? "#FFFFFF" : undefined}
thumbColor={Platform.OS !== "ios" ? "#FFFFFF" : undefined}
/>
</View>
</View>
@@ -229,42 +227,6 @@ function NavigationRow({
);
}
// iOS Native Picker trigger component
function IOSPickerTrigger({
options,
selectedValue,
onSelect,
}: {
options: { label: string; value: string }[];
selectedValue: string;
onSelect: (value: string) => void;
}) {
return (
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{options.map((opt) => (
<Button
key={opt.value}
systemImage={selectedValue === opt.label ? "checkmark" : undefined}
onPress={() => onSelect(opt.value)}
label={opt.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
}
interface AdvancedTabProps {
requiresConfirmation: boolean;
setRequiresConfirmation: (value: boolean) => void;
@@ -5,12 +5,10 @@
* Matches the beautiful styling from AvailabilityDetailScreen.
*/
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Platform, Text, TouchableOpacity, View } from "react-native";
import type { Schedule } from "@/services/calcom";
import { AvailabilityTabIOSPicker } from "./AvailabilityTabIOSPicker";
interface DaySchedule {
day: string;
@@ -103,7 +101,11 @@ function NavigationRow({
{value}
</Text>
) : null}
<IOSPickerTrigger options={options} selectedValue={value || ""} onSelect={onSelect} />
<AvailabilityTabIOSPicker
options={options}
selectedValue={value || ""}
onSelect={onSelect}
/>
</>
) : (
<TouchableOpacity
@@ -126,42 +128,6 @@ function NavigationRow({
);
}
// iOS Native Picker trigger component
function IOSPickerTrigger({
options,
selectedValue,
onSelect,
}: {
options: { label: string; value: string }[];
selectedValue: string;
onSelect: (value: string) => void;
}) {
return (
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{options.map((opt) => (
<Button
key={opt.value}
systemImage={selectedValue === opt.label ? "checkmark" : undefined}
onPress={() => onSelect(opt.value)}
label={opt.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
}
export function AvailabilityTab(props: AvailabilityTabProps) {
const daySchedules = props.getDaySchedules();
const enabledDaysCount = daySchedules.filter((d) => d.available).length;
@@ -0,0 +1,40 @@
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { isLiquidGlassAvailable } from "expo-glass-effect";
interface AvailabilityTabIOSPickerProps {
options: { label: string; value: string }[];
selectedValue: string;
onSelect: (value: string) => void;
}
export function AvailabilityTabIOSPicker({
options,
selectedValue,
onSelect,
}: AvailabilityTabIOSPickerProps) {
return (
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{options.map((opt) => (
<Button
key={opt.value}
systemImage={selectedValue === opt.label ? "checkmark" : undefined}
onPress={() => onSelect(opt.value)}
label={opt.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
}
@@ -0,0 +1,9 @@
interface AvailabilityTabIOSPickerProps {
options: { label: string; value: string }[];
selectedValue: string;
onSelect: (value: string) => void;
}
export function AvailabilityTabIOSPicker(_props: AvailabilityTabIOSPickerProps) {
return null;
}
@@ -4,18 +4,15 @@
* iOS Settings style with grouped input rows and section headers.
*/
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Platform, Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
import { LocationsList, AddLocationTrigger } from "@/components/LocationsList";
import { createLocationItemFromOption } from "@/utils/locationHelpers";
import type { LocationItem, LocationOptionGroup } from "@/types/locations";
import { slugify } from "@/utils/slugify";
import type React from "react";
import { useState } from "react";
import { Platform, Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
import { AddLocationTrigger, LocationsList } from "@/components/LocationsList";
import type { LocationItem, LocationOptionGroup } from "@/types/locations";
import { createLocationItemFromOption } from "@/utils/locationHelpers";
import { slugify } from "@/utils/slugify";
import { BasicsTabIOSPicker } from "./BasicsTabIOSPicker";
interface BasicsTabProps {
eventTitle: string;
@@ -159,7 +156,11 @@ function NavigationRow({
{value}
</Text>
) : null}
<IOSPickerTrigger options={options} selectedValue={value || ""} onSelect={onSelect} />
<BasicsTabIOSPicker
options={options}
selectedValue={value || ""}
onSelect={onSelect}
/>
</>
) : (
<TouchableOpacity
@@ -181,42 +182,6 @@ function NavigationRow({
);
}
// iOS Native Picker trigger component
function IOSPickerTrigger({
options,
selectedValue,
onSelect,
}: {
options: string[];
selectedValue: string;
onSelect: (value: string) => void;
}) {
return (
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{options.map((opt) => (
<Button
key={opt}
systemImage={selectedValue === opt ? "checkmark" : undefined}
onPress={() => onSelect(opt)}
label={opt}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
}
// Toggle row
function SettingRow({
title,
@@ -246,7 +211,7 @@ function SettingRow({
value={value}
onValueChange={onValueChange}
trackColor={{ false: "#E9E9EA", true: "#000000" }}
thumbColor={Platform.OS === "android" ? "#FFFFFF" : undefined}
thumbColor={Platform.OS !== "ios" ? "#FFFFFF" : undefined}
/>
</View>
</View>
@@ -0,0 +1,36 @@
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { isLiquidGlassAvailable } from "expo-glass-effect";
interface BasicsTabIOSPickerProps {
options: string[];
selectedValue: string;
onSelect: (value: string) => void;
}
export function BasicsTabIOSPicker({ options, selectedValue, onSelect }: BasicsTabIOSPickerProps) {
return (
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{options.map((opt) => (
<Button
key={opt}
systemImage={selectedValue === opt ? "checkmark" : undefined}
onPress={() => onSelect(opt)}
label={opt}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
}
@@ -0,0 +1,9 @@
interface BasicsTabIOSPickerProps {
options: string[];
selectedValue: string;
onSelect: (value: string) => void;
}
export function BasicsTabIOSPicker(_props: BasicsTabIOSPickerProps) {
return null;
}
@@ -0,0 +1,36 @@
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { isLiquidGlassAvailable } from "expo-glass-effect";
interface IOSPickerTriggerProps {
options: { label: string; value: string }[];
selectedValue: string;
onSelect: (value: string) => void;
}
export function IOSPickerTrigger({ options, selectedValue, onSelect }: IOSPickerTriggerProps) {
return (
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{options.map((opt) => (
<Button
key={opt.value}
systemImage={selectedValue === opt.label ? "checkmark" : undefined}
onPress={() => onSelect(opt.value)}
label={opt.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
}
@@ -0,0 +1,9 @@
interface IOSPickerTriggerProps {
options: { label: string; value: string }[];
selectedValue: string;
onSelect: (value: string) => void;
}
export function IOSPickerTrigger(_props: IOSPickerTriggerProps) {
return null;
}
@@ -5,10 +5,7 @@
* Uses native iOS ContextMenu for pickers on iOS, Modal-based dropdowns on Android/Web.
*/
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import {
Alert,
type Animated,
@@ -20,6 +17,8 @@ import {
View,
} from "react-native";
import { LimitsTabIOSPicker } from "./LimitsTabIOSPicker";
interface FrequencyLimit {
id: number;
value: string;
@@ -146,7 +145,7 @@ function SettingRow({
value={value}
onValueChange={onValueChange}
trackColor={{ false: "#E9E9EA", true: "#000000" }}
thumbColor={Platform.OS === "android" ? "#FFFFFF" : undefined}
thumbColor={Platform.OS !== "ios" ? "#FFFFFF" : undefined}
/>
</View>
</View>
@@ -154,42 +153,6 @@ function SettingRow({
);
}
// iOS Native Picker using ContextMenu - only renders the chevron trigger
function IOSPickerTrigger({
options,
selectedValue,
onSelect,
}: {
options: string[];
selectedValue: string;
onSelect: (value: string) => void;
}) {
return (
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{options.map((opt) => (
<Button
key={opt}
systemImage={selectedValue === opt ? "checkmark" : undefined}
onPress={() => onSelect(opt)}
label={opt}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
}
// Android/Web picker button (opens modal)
function ModalPickerButton({ value, onPress }: { value: string; onPress: () => void }) {
return (
@@ -234,7 +197,7 @@ function PickerNavigationRow({
{Platform.OS === "ios" ? (
<>
<Text className="mr-2 text-[17px] text-[#8E8E93]">{value}</Text>
<IOSPickerTrigger options={options} selectedValue={value} onSelect={onSelect} />
<LimitsTabIOSPicker options={options} selectedValue={value} onSelect={onSelect} />
</>
) : (
<TouchableOpacity
@@ -350,7 +313,7 @@ export function LimitsTab(props: LimitsTabProps) {
{Platform.OS === "ios" ? (
<View className="flex-row items-center">
<Text className="mr-1 text-[17px] text-[#8E8E93]">{props.minimumNoticeUnit}</Text>
<IOSPickerTrigger
<LimitsTabIOSPicker
options={TIME_UNIT_OPTIONS}
selectedValue={props.minimumNoticeUnit}
onSelect={props.setMinimumNoticeUnit}
@@ -439,7 +402,7 @@ export function LimitsTab(props: LimitsTabProps) {
{Platform.OS === "ios" ? (
<View className="flex-row items-center">
<Text className="mr-1 text-[17px] text-[#8E8E93]">{limit.unit}</Text>
<IOSPickerTrigger
<LimitsTabIOSPicker
options={FREQUENCY_UNIT_OPTIONS}
selectedValue={limit.unit}
onSelect={(value: string) =>
@@ -501,7 +464,7 @@ export function LimitsTab(props: LimitsTabProps) {
{Platform.OS === "ios" ? (
<View className="flex-row items-center">
<Text className="mr-1 text-[17px] text-[#8E8E93]">{limit.unit}</Text>
<IOSPickerTrigger
<LimitsTabIOSPicker
options={DURATION_UNIT_OPTIONS}
selectedValue={limit.unit}
onSelect={(value: string) =>
@@ -0,0 +1,36 @@
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { isLiquidGlassAvailable } from "expo-glass-effect";
interface LimitsTabIOSPickerProps {
options: string[];
selectedValue: string;
onSelect: (value: string) => void;
}
export function LimitsTabIOSPicker({ options, selectedValue, onSelect }: LimitsTabIOSPickerProps) {
return (
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{options.map((opt) => (
<Button
key={opt}
systemImage={selectedValue === opt ? "checkmark" : undefined}
onPress={() => onSelect(opt)}
label={opt}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
}
@@ -0,0 +1,9 @@
interface LimitsTabIOSPickerProps {
options: string[];
selectedValue: string;
onSelect: (value: string) => void;
}
export function LimitsTabIOSPicker(_props: LimitsTabIOSPickerProps) {
return null;
}
@@ -4,13 +4,10 @@
* iOS Settings style with grouped rows and section headers.
*/
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Alert, Platform, Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
import { openInAppBrowser } from "@/utils/browser";
import { RecurringTabIOSPicker } from "./RecurringTabIOSPicker";
interface RecurringTabProps {
recurringEnabled: boolean;
@@ -112,7 +109,7 @@ function SettingRow({
value={value}
onValueChange={onValueChange}
trackColor={{ false: "#E9E9EA", true: "#000000" }}
thumbColor={Platform.OS === "android" ? "#FFFFFF" : undefined}
thumbColor={Platform.OS !== "ios" ? "#FFFFFF" : undefined}
/>
</View>
</View>
@@ -120,42 +117,6 @@ function SettingRow({
);
}
// iOS Native Picker trigger component
function IOSPickerTrigger({
options,
selectedValue,
onSelect,
}: {
options: { label: string; value: string }[];
selectedValue: string;
onSelect: (value: string) => void;
}) {
return (
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{options.map((opt) => (
<Button
key={opt.value}
systemImage={selectedValue === opt.label ? "checkmark" : undefined}
onPress={() => onSelect(opt.value)}
label={opt.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
}
export function RecurringTab({
recurringEnabled,
setRecurringEnabled,
@@ -215,7 +176,7 @@ export function RecurringTab({
<Text className="mr-1 text-[17px] text-[#8E8E93]">
{frequencyToLabel[recurringFrequency] || recurringFrequency}
</Text>
<IOSPickerTrigger
<RecurringTabIOSPicker
options={[
{ label: "week", value: "weekly" },
{ label: "month", value: "monthly" },
@@ -0,0 +1,40 @@
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { isLiquidGlassAvailable } from "expo-glass-effect";
interface RecurringTabIOSPickerProps {
options: { label: string; value: string }[];
selectedValue: string;
onSelect: (value: string) => void;
}
export function RecurringTabIOSPicker({
options,
selectedValue,
onSelect,
}: RecurringTabIOSPickerProps) {
return (
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{options.map((opt) => (
<Button
key={opt.value}
systemImage={selectedValue === opt.label ? "checkmark" : undefined}
onPress={() => onSelect(opt.value)}
label={opt.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
}
@@ -0,0 +1,9 @@
interface RecurringTabIOSPickerProps {
options: { label: string; value: string }[];
selectedValue: string;
onSelect: (value: string) => void;
}
export function RecurringTabIOSPicker(_props: RecurringTabIOSPickerProps) {
return null;
}
@@ -13,8 +13,11 @@ import {
TouchableOpacity,
View,
} from "react-native";
import { FullScreenModal } from "@/components/FullScreenModal";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "@/components/AppPressable";
import { BookingActionsModal } from "@/components/BookingActionsModal";
import { FullScreenModal } from "@/components/FullScreenModal";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import {
AlertDialog,
AlertDialogAction,
@@ -33,19 +36,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Text as UIText } from "@/components/ui/text";
import { AppPressable } from "@/components/AppPressable";
import { BookingActionsModal } from "@/components/BookingActionsModal";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import { SvgImage } from "@/components/SvgImage";
import { useAuth } from "@/contexts/AuthContext";
import { useCancelBooking } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
import { openInAppBrowser } from "@/utils/browser";
import { defaultLocations, getDefaultLocationIconUrl } from "@/utils/defaultLocations";
import { formatAppIdToDisplayName } from "@/utils/formatters";
import { getAppIconUrl } from "@/utils/getAppIconUrl";
// Empty actions result for when no booking is loaded
const EMPTY_ACTIONS: BookingActionsResult = {
@@ -59,136 +55,76 @@ const EMPTY_ACTIONS: BookingActionsResult = {
markNoShow: { visible: false, enabled: false },
};
// Format date: "Tuesday, November 25, 2025"
const formatDateFull = (dateString: string): string => {
// Format date for iOS Calendar style: "Monday, Jan 12, 2026"
const formatDateCalendarStyle = (dateString: string): string => {
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",
month: "short",
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}`;
};
// Format time for iOS Calendar style: "3 PM – 3:30 PM"
const formatTimeCalendarStyle = (startDateString: string, endDateString: string): string => {
if (!startDateString || !endDateString) return "";
const startDate = new Date(startDateString);
const endDate = new Date(endDateString);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return "";
// 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<string, unknown>) => {
// 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,
};
const formatTime = (date: Date): string => {
const hours = date.getHours();
const minutes = date.getMinutes();
const period = hours >= 12 ? "PM" : "AM";
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
if (minutes === 0) {
return `${hour12} ${period}`;
}
// 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,
return `${hour12}:${minutes.toString().padStart(2, "0")} ${period}`;
};
return `${formatTime(startDate)} – ${formatTime(endDate)}`;
};
// Format duration for display: "30 min" or "1 hr" or "1 hr 30 min"
const formatDuration = (minutes: number): string => {
if (minutes < 60) {
return `${minutes} min`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) {
return hours === 1 ? "1 hr" : `${hours} hrs`;
}
return `${hours} hr ${remainingMinutes} min`;
};
// Calculate duration from start and end times
const calculateDuration = (startDateString: string, endDateString: string): number => {
if (!startDateString || !endDateString) return 0;
const startDate = new Date(startDateString);
const endDate = new Date(endDateString);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return 0;
return Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60));
};
const getMeetingUrl = (booking: Booking | null): string | null => {
if (!booking) return null;
const videoCallUrl = booking.responses?.videoCallUrl;
if (typeof videoCallUrl === "string" && videoCallUrl.startsWith("http")) {
return videoCallUrl;
}
const location = booking.location;
if (typeof location === "string" && location.startsWith("http")) {
return location;
}
return null;
};
export interface BookingDetailScreenProps {
@@ -242,6 +178,7 @@ export function BookingDetailScreen({
const [showActionsModal, setShowActionsModal] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [cancellationReason, setCancellationReason] = useState("");
const [participantsExpanded, setParticipantsExpanded] = useState(true);
// Cancel booking mutation
const cancelBookingMutation = useCancelBooking();
@@ -402,6 +339,14 @@ export function BookingDetailScreen({
});
}, [booking, router]);
const meetingUrl = useMemo(() => getMeetingUrl(booking ?? null), [booking]);
const handleJoinMeeting = useCallback(() => {
if (meetingUrl) {
openInAppBrowser(meetingUrl, "meeting link");
}
}, [meetingUrl]);
// Expose action handlers to parent component (for iOS header menu)
useEffect(() => {
if (booking && onActionsReady) {
@@ -427,15 +372,6 @@ export function BookingDetailScreen({
openMarkNoShowModal,
]);
const handleJoinMeeting = () => {
if (!booking?.location) return;
const provider = getLocationProvider(booking.location);
if (provider?.url) {
openInAppBrowser(provider.url, "meeting link");
}
};
const dropdownActions = useMemo(() => {
if (!booking) return [];
@@ -520,7 +456,7 @@ export function BookingDetailScreen({
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
<View className="flex-1 items-center justify-center bg-[#f2f2f7]">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-gray-500">Loading booking...</Text>
</View>
@@ -530,8 +466,8 @@ export function BookingDetailScreen({
if (error || !booking) {
const errorMessage = error?.message || "Booking not found";
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa] p-5">
<Ionicons name="alert-circle" size={64} color="#800020" />
<View className="flex-1 items-center justify-center bg-[#f2f2f7] p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
{errorMessage}
</Text>
@@ -544,10 +480,55 @@ export function BookingDetailScreen({
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);
const dateFormatted = formatDateCalendarStyle(startTime);
const timeFormatted = formatTimeCalendarStyle(startTime, endTime);
const isRecurring =
booking.recurringEventId || (booking as { recurringBookingUid?: string }).recurringBookingUid;
const duration =
(booking as { duration?: number }).duration || calculateDuration(startTime, endTime);
const durationFormatted = duration > 0 ? formatDuration(duration) : null;
const eventTypeSlug = booking.eventType?.slug;
const hostsCount = booking.hosts?.length || (booking.user ? 1 : 0);
const attendeesCount = booking.attendees?.length || 0;
const guestsCount = (booking as { guests?: string[] }).guests?.length || 0;
const totalParticipants = hostsCount + attendeesCount + guestsCount;
const isPastBooking = new Date(endTime) < new Date();
const normalizedStatus = booking.status.toLowerCase();
const getAttendeeStatusIcon = (attendee: { noShow?: boolean; absent?: boolean }) => {
const isNoShow = attendee.noShow || attendee.absent;
if (isPastBooking && isNoShow) {
return {
name: "close-circle" as const,
color: "#FF3B30",
label: "No-show",
};
}
if (normalizedStatus === "pending") {
return {
name: "help-circle" as const,
color: "#8E8E93",
label: "Pending",
};
}
if (normalizedStatus === "cancelled" || normalizedStatus === "rejected") {
return {
name: "close-circle-outline" as const,
color: "#8E8E93",
label: null,
};
}
return { name: "checkmark-circle" as const, color: "#34C759", label: null };
};
return (
<>
@@ -557,236 +538,202 @@ export function BookingDetailScreen({
options={{
headerRight: () => (
<HeaderButtonWrapper side="right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Pressable className="h-10 w-10 items-center justify-center rounded-full">
<Ionicons name="ellipsis-horizontal" size={24} color="#000" />
<View className="flex-row items-center">
{meetingUrl && (
<Pressable
className="mr-2 h-10 w-10 items-center justify-center rounded-full"
onPress={handleJoinMeeting}
>
<Ionicons name="videocam" size={24} color="#000" />
</Pressable>
</DropdownMenuTrigger>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Pressable className="h-10 w-10 items-center justify-center rounded-full">
<Ionicons name="ellipsis-horizontal" size={24} color="#000" />
</Pressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={contentInsets}
sideOffset={8}
className="w-52"
align="end"
>
{dropdownActions.map((action, index) => (
<React.Fragment key={action.label}>
{index === destructiveStartIndex && destructiveStartIndex > 0 && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem variant={action.variant} onPress={action.onPress}>
<Ionicons
name={action.icon}
size={18}
color={action.variant === "destructive" ? "#800020" : "#374151"}
style={{ marginRight: 8 }}
/>
<UIText
className={action.variant === "destructive" ? "text-destructive" : ""}
>
{action.label}
</UIText>
</DropdownMenuItem>
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuContent
insets={contentInsets}
sideOffset={8}
className="w-52"
align="end"
>
{dropdownActions.map((action, index) => (
<React.Fragment key={action.label}>
{index === destructiveStartIndex && destructiveStartIndex > 0 && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem variant={action.variant} onPress={action.onPress}>
<Ionicons
name={action.icon}
size={18}
color={action.variant === "destructive" ? "#800020" : "#374151"}
style={{ marginRight: 8 }}
/>
<UIText
className={action.variant === "destructive" ? "text-destructive" : ""}
>
{action.label}
</UIText>
</DropdownMenuItem>
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
</HeaderButtonWrapper>
),
}}
/>
)}
<View className="flex-1 bg-[#f8f9fa]">
<View className="flex-1 bg-[#f2f2f7]">
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 100 }}
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 100 }}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
>
{/* Title */}
<View className="mb-3">
<Text className="mb-2 text-2xl font-semibold text-[#333]">{booking.title}</Text>
<Text className="text-base text-[#666]">
{dateFormatted} {timeFormatted} ({timezone})
{/* Title Section - iOS Calendar Style */}
<View className="mb-8">
<Text
className="mb-4 text-[26px] font-semibold leading-tight text-black"
style={{ letterSpacing: -0.3 }}
>
{booking.title}
</Text>
</View>
{/* Who Section */}
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-4 text-base font-medium text-[#666]">Who</Text>
{/* Show host from user field or hosts array */}
{booking.user || (booking.hosts && booking.hosts.length > 0) ? (
<View className="mb-4">
{booking.user ? (
<View className="flex-row items-start">
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(booking.user.name)}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-medium text-[#333]">
{booking.user.name}
</Text>
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
<Text className="text-xs font-medium text-white">host</Text>
</View>
</View>
<Text className="text-sm text-[#666]">{booking.user.email}</Text>
</View>
{eventTypeSlug || durationFormatted ? (
<View className="mb-3 flex-row items-center">
{eventTypeSlug ? (
<Text className="text-[15px] text-[#8E8E93]">{eventTypeSlug}</Text>
) : null}
{eventTypeSlug && durationFormatted ? (
<Text className="mx-2 text-[15px] text-[#C7C7CC]">•</Text>
) : null}
{durationFormatted ? (
<View className="rounded-full bg-[#E5E5EA] px-2.5 py-1">
<Text className="text-[13px] font-medium text-[#636366]">
{durationFormatted}
</Text>
</View>
) : booking.hosts && booking.hosts.length > 0 ? (
booking.hosts.map((host, hostIndex) => (
<View
key={host.email ?? host.name}
className={`flex-row items-start ${hostIndex > 0 ? "mt-4" : ""}`}
>
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(host.name || "Host")}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-medium text-[#333]">
{host.name || "Host"}
</Text>
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
<Text className="text-xs font-medium text-white">host</Text>
</View>
</View>
{host.email && <Text className="text-sm text-[#666]">{host.email}</Text>}
</View>
</View>
))
) : null}
</View>
) : null}
{booking.attendees && booking.attendees.length > 0 ? (
<View>
{booking.attendees.map((attendee, index) => {
const isNoShow =
(attendee as { noShow?: boolean; absent?: boolean }).noShow === true ||
(attendee as { noShow?: boolean; absent?: boolean }).absent === true;
<Text className="mb-0.5 text-[17px] text-black">{dateFormatted}</Text>
<Text className="mb-0.5 text-[17px] text-black">{timeFormatted}</Text>
{isRecurring ? (
<Text className="mt-0.5 text-[17px] text-[#800020]">Repeats weekly</Text>
) : null}
</View>
{/* Participants Card - iOS Calendar Style (Expandable) */}
<View className="mb-4 overflow-hidden rounded-xl bg-white">
<AppPressable onPress={() => setParticipantsExpanded(!participantsExpanded)}>
<View className="flex-row items-center justify-between px-4 py-3.5">
<Text className="text-[17px] text-black">Participants</Text>
<View className="flex-row items-center">
<Text className="mr-1 text-[17px] text-[#8E8E93]">{totalParticipants}</Text>
<Ionicons
name={participantsExpanded ? "chevron-down" : "chevron-forward"}
size={18}
color="#C7C7CC"
/>
</View>
</View>
</AppPressable>
{participantsExpanded ? (
<View className="border-t border-[#E5E5EA] px-4 py-2.5">
{/* Hosts */}
{booking.hosts && booking.hosts.length > 0 ? (
booking.hosts.map((host, index) => (
<View
key={host.email || `host-${index}`}
className={`flex-row items-center py-2 ${
index > 0 ? "border-t border-[#E5E5EA]" : ""
}`}
>
<Ionicons name="star" size={18} color="#FFD60A" />
<Text className="ml-2.5 flex-1 text-[15px] text-black" numberOfLines={1}>
{host.name || host.email || "Host"}
</Text>
<Text className="text-[13px] text-[#8E8E93]">Organizer</Text>
</View>
))
) : booking.user ? (
<View className="flex-row items-center py-2">
<Ionicons name="star" size={18} color="#FFD60A" />
<Text className="ml-2.5 flex-1 text-[15px] text-black" numberOfLines={1}>
{booking.user.name || booking.user.email}
</Text>
<Text className="text-[13px] text-[#8E8E93]">Organizer</Text>
</View>
) : null}
{/* Attendees */}
{booking.attendees?.map((attendee, index) => {
const statusIcon = getAttendeeStatusIcon(attendee);
return (
<View
key={attendee.email}
className={`flex-row items-start ${index > 0 ? "mt-4" : ""}`}
className={`flex-row items-center py-2 ${
index > 0 || booking.hosts?.length || booking.user
? "border-t border-[#E5E5EA]"
: ""
}`}
>
<View
className={`mr-3 h-12 w-12 items-center justify-center rounded-full ${
isNoShow ? "bg-[#800020]" : "bg-black"
}`}
>
<Text className="text-base font-semibold text-white">
{getInitials(attendee.name)}
<Ionicons name={statusIcon.name} size={20} color={statusIcon.color} />
<Text className="ml-2.5 flex-1 text-[15px] text-black" numberOfLines={1}>
{attendee.name || attendee.email}
</Text>
{statusIcon.label ? (
<Text className="text-[13px]" style={{ color: statusIcon.color }}>
{statusIcon.label}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row items-center">
<Text
className={`text-base font-medium ${
isNoShow ? "text-[#800020]" : "text-[#333]"
}`}
>
{attendee.name}
</Text>
{isNoShow && (
<View className="ml-2 flex-row items-center rounded-full bg-[#FEE2E2] px-2 py-0.5">
<Ionicons name="eye-off" size={12} color="#800020" />
<Text className="ml-1 text-xs font-medium text-[#800020]">
No-show
</Text>
</View>
)}
</View>
<Text className={`text-sm ${isNoShow ? "text-[#800020]" : "text-[#666]"}`}>
{attendee.email}
</Text>
</View>
) : null}
</View>
);
})}
{/* Guests */}
{(booking as { guests?: string[] }).guests?.map((guestEmail, index) => (
<View
key={guestEmail}
className={`flex-row items-center py-2 ${
index > 0 ||
booking.attendees?.length ||
booking.hosts?.length ||
booking.user
? "border-t border-[#E5E5EA]"
: ""
}`}
>
<Ionicons name="person-outline" size={20} color="#8E8E93" />
<Text className="ml-2.5 flex-1 text-[15px] text-[#8E8E93]" numberOfLines={1}>
{guestEmail}
</Text>
<Text className="text-[13px] text-[#8E8E93]">Guest</Text>
</View>
))}
</View>
) : null}
</View>
{/* Where Section */}
{locationProvider ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-4 text-base font-medium text-[#666]">Where</Text>
{locationProvider.url ? (
<AppPressable
onPress={handleJoinMeeting}
className="flex-row flex-wrap items-center"
>
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
) : null}
<Text className="text-base text-[#007AFF]">{locationProvider.label}: </Text>
<Text className="flex-1 text-base text-[#007AFF]" numberOfLines={1}>
{locationProvider.url}
</Text>
</AppPressable>
) : (
<View className="flex-row items-center">
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
) : null}
<Text className="text-base text-[#333]">{locationProvider.label}</Text>
</View>
)}
</View>
) : null}
{/* Recurring Event Section */}
{booking.recurringEventId ||
(booking as { recurringBookingUid?: string }).recurringBookingUid ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="text-base font-medium text-[#666]">
This is part of a recurring event
</Text>
</View>
) : null}
{/* Description Section */}
{/* Notes Card (if available) */}
{booking.description ? (
<View className="mb-2 rounded-2xl bg-white p-6">
<Text className="mb-2 text-base font-medium text-[#666]">Description</Text>
<Text className="text-base leading-6 text-[#666]">{booking.description}</Text>
<View className="mb-4 overflow-hidden rounded-xl bg-white">
<View className="px-4 py-3.5">
<Text className="mb-1.5 text-[13px] font-medium uppercase tracking-wide text-[#8E8E93]">
Notes
</Text>
<Text className="text-[17px] leading-6 text-black">{booking.description}</Text>
</View>
</View>
) : null}
{/* Join Meeting Button */}
{locationProvider?.url ? (
<AppPressable
onPress={handleJoinMeeting}
className="mb-2 flex-row items-center justify-center rounded-lg bg-black px-6 py-4"
>
{locationProvider.iconUrl ? (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
) : null}
<Text className="text-base font-semibold text-white">
Join {locationProvider.label}
</Text>
</AppPressable>
) : null}
</ScrollView>
{/* Booking Actions Modal */}
@@ -808,7 +755,7 @@ export function BookingDetailScreen({
/>
{/* Cancelling overlay */}
{isCancelling && (
{isCancelling ? (
<View className="absolute inset-0 items-center justify-center bg-black/50">
<View className="rounded-2xl bg-white px-8 py-6">
<ActivityIndicator size="large" color="#000" />
@@ -817,7 +764,7 @@ export function BookingDetailScreen({
</Text>
</View>
</View>
)}
) : null}
</View>
{/* Web/Extension: Cancel Event Modal */}