diff --git a/companion/app/(tabs)/(availability)/_layout.tsx b/companion/app/(tabs)/(availability)/_layout.tsx index 186d570bd3b86b..d175a850d2c834 100644 --- a/companion/app/(tabs)/(availability)/_layout.tsx +++ b/companion/app/(tabs)/(availability)/_layout.tsx @@ -1,9 +1,11 @@ import { Stack } from "expo-router"; +import { Platform } from "react-native"; export default function AvailabilityLayout() { return ( + ); } diff --git a/companion/app/(tabs)/(availability)/availability-detail.ios.tsx b/companion/app/(tabs)/(availability)/availability-detail.ios.tsx new file mode 100644 index 00000000000000..40b631a3b65c01 --- /dev/null +++ b/companion/app/(tabs)/(availability)/availability-detail.ios.tsx @@ -0,0 +1,118 @@ +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useRef } from "react"; +import { + AvailabilityDetailScreen, + type AvailabilityDetailScreenHandle, +} from "@/components/screens/AvailabilityDetailScreen"; + +// Type for action handlers exposed by AvailabilityDetailScreen.ios.tsx +type ActionHandlers = { + handleSetAsDefault: () => void; + handleDelete: () => void; +}; + +export default function AvailabilityDetailIOS() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + + // Ref to store action handlers from AvailabilityDetailScreen + const actionHandlersRef = useRef(null); + const screenRef = useRef(null); + + // Callback to receive action handlers from AvailabilityDetailScreen + const handleActionsReady = useCallback((handlers: ActionHandlers) => { + actionHandlersRef.current = handlers; + }, []); + + // Navigation handlers for edit bottom sheets + const handleEditNameAndTimezone = useCallback(() => { + router.push(`/edit-availability-name?id=${id}` as never); + }, [router, id]); + + const handleEditWorkingHours = useCallback(() => { + router.push(`/edit-availability-hours?id=${id}` as never); + }, [router, id]); + + const handleEditOverride = useCallback(() => { + router.push(`/edit-availability-override?id=${id}` as never); + }, [router, id]); + + // Action handlers for inline actions + const handleSetAsDefault = useCallback(() => { + if (actionHandlersRef.current?.handleSetAsDefault) { + actionHandlersRef.current.handleSetAsDefault(); + } else if (screenRef.current?.setAsDefault) { + screenRef.current.setAsDefault(); + } + }, []); + + const handleDelete = useCallback(() => { + if (actionHandlersRef.current?.handleDelete) { + actionHandlersRef.current.handleDelete(); + } else if (screenRef.current?.delete) { + screenRef.current.delete(); + } + }, []); + + if (!id) { + return null; + } + + return ( + <> + + + + + {/* Edit Menu */} + + Edit + + {/* Name and Timezone */} + + Name and Timezone + + + {/* Working Hours */} + + Working Hours + + + {/* Date Override */} + + Date Override + + + {/* Set as Default */} + + Set as Default + + + {/* Delete */} + + Delete Schedule + + + + + + + + ); +} diff --git a/companion/app/availability-detail.tsx b/companion/app/(tabs)/(availability)/availability-detail.tsx similarity index 100% rename from companion/app/availability-detail.tsx rename to companion/app/(tabs)/(availability)/availability-detail.tsx diff --git a/companion/app/(tabs)/(availability)/index.tsx b/companion/app/(tabs)/(availability)/index.tsx index 25c7f46ce1fb0a..7ee3dae20456b5 100644 --- a/companion/app/(tabs)/(availability)/index.tsx +++ b/companion/app/(tabs)/(availability)/index.tsx @@ -60,7 +60,7 @@ export default function Availability() { onSuccess: (newSchedule) => { // Navigate to edit the newly created schedule router.push({ - pathname: "/availability-detail", + pathname: "/(tabs)/(availability)/availability-detail", params: { id: newSchedule.id.toString(), }, diff --git a/companion/app/(tabs)/(event-types)/event-type-detail.tsx b/companion/app/(tabs)/(event-types)/event-type-detail.tsx index bc7dcc6c7602a7..c3772adc151a61 100644 --- a/companion/app/(tabs)/(event-types)/event-type-detail.tsx +++ b/companion/app/(tabs)/(event-types)/event-type-detail.tsx @@ -13,7 +13,6 @@ import { TouchableOpacity, View, } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppPressable } from "@/components/AppPressable"; import { AdvancedTab } from "@/components/event-type-detail/tabs/AdvancedTab"; import { AvailabilityTab } from "@/components/event-type-detail/tabs/AvailabilityTab"; @@ -127,7 +126,6 @@ export default function EventTypeDetail() { slug?: string; }>(); - const insets = useSafeAreaInsets(); const [activeTab, setActiveTab] = useState("basics"); // Form state diff --git a/companion/app/_layout.tsx b/companion/app/_layout.tsx index b5a7769b99a6a4..c34dbaeaabde8a 100644 --- a/companion/app/_layout.tsx +++ b/companion/app/_layout.tsx @@ -198,6 +198,110 @@ function RootLayoutContent() { headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light", }} /> + + + + ) : ( diff --git a/companion/app/edit-availability-day.ios.tsx b/companion/app/edit-availability-day.ios.tsx new file mode 100644 index 00000000000000..67c8a645dcb895 --- /dev/null +++ b/companion/app/edit-availability-day.ios.tsx @@ -0,0 +1,124 @@ +import { osName } from "expo-device"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Alert, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { EditAvailabilityDayScreenHandle } from "@/components/screens/EditAvailabilityDayScreen.ios"; +import EditAvailabilityDayScreenComponent from "@/components/screens/EditAvailabilityDayScreen.ios"; +import { CalComAPIService, type Schedule } from "@/services/calcom"; + +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +// Semi-transparent background to prevent black flash while preserving glass effect +const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)"; + +function getPresentationStyle(): "formSheet" | "modal" { + if (isLiquidGlassAvailable() && osName !== "iPadOS") { + return "formSheet"; + } + return "modal"; +} + +export default function EditAvailabilityDayIOS() { + const { id, day } = useLocalSearchParams<{ id: string; day: string }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [schedule, setSchedule] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const screenRef = useRef(null); + + const dayIndex = day ? parseInt(day, 10) : 0; + const dayName = DAYS[dayIndex] || "Day"; + + useEffect(() => { + if (id) { + setIsLoading(true); + CalComAPIService.getScheduleById(Number(id)) + .then(setSchedule) + .catch(() => { + Alert.alert("Error", "Failed to load schedule details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Schedule ID is missing"); + router.back(); + } + }, [id, router]); + + const handleSave = useCallback(() => { + screenRef.current?.submit(); + }, []); + + const handleSuccess = useCallback(() => { + router.back(); + }, [router]); + + const presentationStyle = getPresentationStyle(); + const useGlassEffect = isLiquidGlassAvailable(); + + return ( + <> + + + + + router.back()}> + + + + + {dayName} + + + + + + + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/edit-availability-day.tsx b/companion/app/edit-availability-day.tsx new file mode 100644 index 00000000000000..9fd6233da8cafc --- /dev/null +++ b/companion/app/edit-availability-day.tsx @@ -0,0 +1,88 @@ +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Alert, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { EditAvailabilityDayScreenHandle } from "@/components/screens/EditAvailabilityDayScreen"; +import EditAvailabilityDayScreenComponent from "@/components/screens/EditAvailabilityDayScreen"; +import { CalComAPIService, type Schedule } from "@/services/calcom"; + +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +export default function EditAvailabilityDay() { + const { id, day } = useLocalSearchParams<{ id: string; day: string }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [schedule, setSchedule] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const screenRef = useRef(null); + + const dayIndex = day ? parseInt(day, 10) : 0; + const dayName = DAYS[dayIndex] || "Day"; + + useEffect(() => { + if (id) { + setIsLoading(true); + CalComAPIService.getScheduleById(Number(id)) + .then(setSchedule) + .catch(() => { + Alert.alert("Error", "Failed to load schedule details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Schedule ID is missing"); + router.back(); + } + }, [id, router]); + + const handleSave = useCallback(() => { + if (isSaving) return; + screenRef.current?.submit(); + }, [isSaving]); + + const handleSuccess = useCallback(() => { + router.back(); + }, [router]); + + if (isLoading) { + return ( + + + + + ); + } + + return ( + + ( + + {isSaving ? "Saving..." : "Save"} + + ), + }} + /> + + + ); +} diff --git a/companion/app/edit-availability-hours.ios.tsx b/companion/app/edit-availability-hours.ios.tsx new file mode 100644 index 00000000000000..a6fbbcb62e25aa --- /dev/null +++ b/companion/app/edit-availability-hours.ios.tsx @@ -0,0 +1,100 @@ +import { osName } from "expo-device"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { ActivityIndicator, Alert, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import EditAvailabilityHoursScreenComponent from "@/components/screens/EditAvailabilityHoursScreen.ios"; +import { CalComAPIService, type Schedule } from "@/services/calcom"; + +// Semi-transparent background to prevent black flash while preserving glass effect +const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)"; + +function getPresentationStyle(): "formSheet" | "modal" { + if (isLiquidGlassAvailable() && osName !== "iPadOS") { + return "formSheet"; + } + return "modal"; +} + +export default function EditAvailabilityHoursIOS() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [schedule, setSchedule] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (id) { + setIsLoading(true); + CalComAPIService.getScheduleById(Number(id)) + .then(setSchedule) + .catch(() => { + Alert.alert("Error", "Failed to load schedule details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Schedule ID is missing"); + router.back(); + } + }, [id, router]); + + const handleDayPress = useCallback( + (dayIndex: number) => { + router.push(`/edit-availability-day?id=${id}&day=${dayIndex}` as never); + }, + [router, id] + ); + + const presentationStyle = getPresentationStyle(); + const useGlassEffect = isLiquidGlassAvailable(); + + return ( + <> + + + + + router.back()}> + + + + + Working Hours + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/edit-availability-hours.tsx b/companion/app/edit-availability-hours.tsx new file mode 100644 index 00000000000000..d5814bab60256a --- /dev/null +++ b/companion/app/edit-availability-hours.tsx @@ -0,0 +1,57 @@ +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { ActivityIndicator, Alert, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import EditAvailabilityHoursScreenComponent from "@/components/screens/EditAvailabilityHoursScreen"; +import { CalComAPIService, type Schedule } from "@/services/calcom"; + +export default function EditAvailabilityHours() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [schedule, setSchedule] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (id) { + setIsLoading(true); + CalComAPIService.getScheduleById(Number(id)) + .then(setSchedule) + .catch(() => { + Alert.alert("Error", "Failed to load schedule details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Schedule ID is missing"); + router.back(); + } + }, [id, router]); + + const handleDayPress = useCallback( + (dayIndex: number) => { + router.push(`/edit-availability-day?id=${id}&day=${dayIndex}` as never); + }, + [router, id] + ); + + if (isLoading) { + return ( + + + + + ); + } + + return ( + + + + + ); +} diff --git a/companion/app/edit-availability-name.ios.tsx b/companion/app/edit-availability-name.ios.tsx new file mode 100644 index 00000000000000..dcbb39b194fc7e --- /dev/null +++ b/companion/app/edit-availability-name.ios.tsx @@ -0,0 +1,118 @@ +import { osName } from "expo-device"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Alert, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { EditAvailabilityNameScreenHandle } from "@/components/screens/EditAvailabilityNameScreen.ios"; +import EditAvailabilityNameScreenComponent from "@/components/screens/EditAvailabilityNameScreen.ios"; +import { CalComAPIService, type Schedule } from "@/services/calcom"; + +// Semi-transparent background to prevent black flash while preserving glass effect +const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)"; + +function getPresentationStyle(): "formSheet" | "modal" { + if (isLiquidGlassAvailable() && osName !== "iPadOS") { + return "formSheet"; + } + return "modal"; +} + +export default function EditAvailabilityNameIOS() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [schedule, setSchedule] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const screenRef = useRef(null); + + useEffect(() => { + if (id) { + setIsLoading(true); + CalComAPIService.getScheduleById(Number(id)) + .then(setSchedule) + .catch(() => { + Alert.alert("Error", "Failed to load schedule details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Schedule ID is missing"); + router.back(); + } + }, [id, router]); + + const handleSave = useCallback(() => { + screenRef.current?.submit(); + }, []); + + const handleSuccess = useCallback(() => { + router.back(); + }, [router]); + + const presentationStyle = getPresentationStyle(); + const useGlassEffect = isLiquidGlassAvailable(); + + return ( + <> + + + + + router.back()}> + + + + + Edit Name & Timezone + + + + + + + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/edit-availability-name.tsx b/companion/app/edit-availability-name.tsx new file mode 100644 index 00000000000000..1f994f39c8dcc0 --- /dev/null +++ b/companion/app/edit-availability-name.tsx @@ -0,0 +1,82 @@ +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Alert, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { EditAvailabilityNameScreenHandle } from "@/components/screens/EditAvailabilityNameScreen"; +import EditAvailabilityNameScreenComponent from "@/components/screens/EditAvailabilityNameScreen"; +import { CalComAPIService, type Schedule } from "@/services/calcom"; + +export default function EditAvailabilityName() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [schedule, setSchedule] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const screenRef = useRef(null); + + useEffect(() => { + if (id) { + setIsLoading(true); + CalComAPIService.getScheduleById(Number(id)) + .then(setSchedule) + .catch(() => { + Alert.alert("Error", "Failed to load schedule details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Schedule ID is missing"); + router.back(); + } + }, [id, router]); + + const handleSave = useCallback(() => { + if (isSaving) return; + screenRef.current?.submit(); + }, [isSaving]); + + const handleSuccess = useCallback(() => { + router.back(); + }, [router]); + + if (isLoading) { + return ( + + + + + ); + } + + return ( + + ( + + {isSaving ? "Saving..." : "Save"} + + ), + }} + /> + + + ); +} diff --git a/companion/app/edit-availability-override.ios.tsx b/companion/app/edit-availability-override.ios.tsx new file mode 100644 index 00000000000000..c288131cc1a203 --- /dev/null +++ b/companion/app/edit-availability-override.ios.tsx @@ -0,0 +1,135 @@ +import { osName } from "expo-device"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Alert, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { EditAvailabilityOverrideScreenHandle } from "@/components/screens/EditAvailabilityOverrideScreen.ios"; +import EditAvailabilityOverrideScreenComponent from "@/components/screens/EditAvailabilityOverrideScreen.ios"; +import { CalComAPIService, type Schedule } from "@/services/calcom"; + +// Semi-transparent background to prevent black flash while preserving glass effect +const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)"; + +function getPresentationStyle(): "formSheet" | "modal" { + if (isLiquidGlassAvailable() && osName !== "iPadOS") { + return "formSheet"; + } + return "modal"; +} + +export default function EditAvailabilityOverrideIOS() { + const { id, overrideIndex } = useLocalSearchParams<{ + id: string; + overrideIndex?: string; + }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [schedule, setSchedule] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const screenRef = useRef(null); + + const editingIndex = overrideIndex ? parseInt(overrideIndex, 10) : undefined; + const isEditing = editingIndex !== undefined; + + useEffect(() => { + if (id) { + setIsLoading(true); + CalComAPIService.getScheduleById(Number(id)) + .then(setSchedule) + .catch(() => { + Alert.alert("Error", "Failed to load schedule details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Schedule ID is missing"); + router.back(); + } + }, [id, router]); + + const handleSave = useCallback(() => { + screenRef.current?.submit(); + }, []); + + const handleSuccess = useCallback(() => { + router.back(); + }, [router]); + + const handleEditOverride = useCallback( + (index: number) => { + // Push a new edit screen on top of the current one + // This allows user to go back to the override list after editing + router.push(`/edit-availability-override?id=${id}&overrideIndex=${index}` as never); + }, + [id, router] + ); + + const presentationStyle = getPresentationStyle(); + const useGlassEffect = isLiquidGlassAvailable(); + + return ( + <> + + + + + router.back()}> + + + + + {isEditing ? "Edit Override" : "Add Override"} + + + + + + + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/edit-availability-override.tsx b/companion/app/edit-availability-override.tsx new file mode 100644 index 00000000000000..6a079c88951219 --- /dev/null +++ b/companion/app/edit-availability-override.tsx @@ -0,0 +1,101 @@ +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Alert, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { EditAvailabilityOverrideScreenHandle } from "@/components/screens/EditAvailabilityOverrideScreen"; +import EditAvailabilityOverrideScreenComponent from "@/components/screens/EditAvailabilityOverrideScreen"; +import { CalComAPIService, type Schedule } from "@/services/calcom"; + +export default function EditAvailabilityOverride() { + const { id, overrideIndex } = useLocalSearchParams<{ + id: string; + overrideIndex?: string; + }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [schedule, setSchedule] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const screenRef = useRef(null); + + const editingIndex = overrideIndex ? parseInt(overrideIndex, 10) : undefined; + const isEditing = editingIndex !== undefined; + + useEffect(() => { + if (id) { + setIsLoading(true); + CalComAPIService.getScheduleById(Number(id)) + .then(setSchedule) + .catch(() => { + Alert.alert("Error", "Failed to load schedule details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Schedule ID is missing"); + router.back(); + } + }, [id, router]); + + const handleSave = useCallback(() => { + if (isSaving) return; + screenRef.current?.submit(); + }, [isSaving]); + + const handleSuccess = useCallback(() => { + router.back(); + }, [router]); + + const handleEditOverride = useCallback( + (index: number) => { + // Push a new edit screen on top of the current one + // This allows user to go back to the override list after editing + router.push(`/edit-availability-override?id=${id}&overrideIndex=${index}` as never); + }, + [id, router] + ); + + const title = isEditing ? "Edit Override" : "Add Override"; + + if (isLoading) { + return ( + + + + + ); + } + + return ( + + ( + + {isSaving ? "Saving..." : "Save"} + + ), + }} + /> + + + ); +} diff --git a/companion/bun.lock b/companion/bun.lock index 61803209ddd514..d84ea978e535e1 100644 --- a/companion/bun.lock +++ b/companion/bun.lock @@ -9,14 +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", + "@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", + "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", @@ -33,7 +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", + "lucide-react-native": "0.562.0", "nativewind": "4.2.1", "react": "19.2.3", "react-dom": "19.2.3", @@ -45,8 +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", + "tailwind-merge": "3.4.0", + "tailwindcss-animate": "1.0.7", }, "devDependencies": { "@biomejs/biome": "2.3.10", diff --git a/companion/components/availability-list-item/AvailabilityListItem.android.tsx b/companion/components/availability-list-item/AvailabilityListItem.android.tsx index 74be9a9b2f487e..155faed5250c29 100644 --- a/companion/components/availability-list-item/AvailabilityListItem.android.tsx +++ b/companion/components/availability-list-item/AvailabilityListItem.android.tsx @@ -89,8 +89,9 @@ export const AvailabilityListItem = ({ onPress={() => handleSchedulePress(schedule)} className="mr-4 flex-1" android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }} + style={{ minWidth: 0 }} > - + @@ -102,7 +103,7 @@ export const AvailabilityListItem = ({ diff --git a/companion/components/availability-list-item/AvailabilityListItem.ios.tsx b/companion/components/availability-list-item/AvailabilityListItem.ios.tsx index 1140f587a2e32e..5e0debaefb9c98 100644 --- a/companion/components/availability-list-item/AvailabilityListItem.ios.tsx +++ b/companion/components/availability-list-item/AvailabilityListItem.ios.tsx @@ -71,8 +71,9 @@ export const AvailabilityListItem = ({ onPress={() => handleSchedulePress(schedule)} className="mr-4 flex-1" accessibilityRole="button" + style={{ minWidth: 0 }} > - + - + - + + + ); diff --git a/companion/components/availability-list-item/AvailabilityListItemParts.tsx b/companion/components/availability-list-item/AvailabilityListItemParts.tsx index 8ce5f4edd57532..3dbfd46f7e4bad 100644 --- a/companion/components/availability-list-item/AvailabilityListItemParts.tsx +++ b/companion/components/availability-list-item/AvailabilityListItemParts.tsx @@ -2,6 +2,16 @@ import { Ionicons } from "@expo/vector-icons"; import { Text, TouchableOpacity, View } from "react-native"; import type { Schedule } from "@/hooks"; +// Convert 24-hour time to 12-hour format with AM/PM +const formatTime12Hour = (time24: string): string => { + const [hours, minutes] = time24.split(":"); + const hour = parseInt(hours, 10); + const min = minutes || "00"; + const period = hour >= 12 ? "PM" : "AM"; + const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + return `${hour12}:${min} ${period}`; +}; + interface ScheduleNameProps { name: string; isDefault?: boolean; @@ -30,18 +40,28 @@ export function AvailabilitySlots({ availability, scheduleId }: AvailabilitySlot return No availability set; } + const MAX_VISIBLE_SLOTS = 2; + const visibleSlots = availability.slice(0, MAX_VISIBLE_SLOTS); + const remainingCount = availability.length - MAX_VISIBLE_SLOTS; + return ( - {availability.map((slot, slotIndex) => ( + {visibleSlots.map((slot, slotIndex) => ( 0 ? "mt-2" : ""} + className={slotIndex > 0 ? "mt-1" : ""} > - - {slot.days.join(", ")} {slot.startTime} - {slot.endTime} + + {slot.days.join(", ")} {formatTime12Hour(slot.startTime)} -{" "} + {formatTime12Hour(slot.endTime)} ))} + {remainingCount > 0 && ( + + +{remainingCount} more {remainingCount === 1 ? "slot" : "slots"} + + )} ); } diff --git a/companion/components/screens/AddGuestsScreen.tsx b/companion/components/screens/AddGuestsScreen.tsx index e4d112b4b1a056..9123d17b21b625 100644 --- a/companion/components/screens/AddGuestsScreen.tsx +++ b/companion/components/screens/AddGuestsScreen.tsx @@ -93,7 +93,7 @@ export const AddGuestsScreen = forwardRef { - if (!booking) return; + if (!booking || isSaving) return; if (guests.length === 0) { Alert.alert("Error", "Please add at least one guest"); @@ -110,7 +110,7 @@ export const AddGuestsScreen = forwardRef = { + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, +}; + +// Convert 24-hour time to 12-hour format with AM/PM +const formatTime12Hour = (time24: string): string => { + const [hours, minutes] = time24.split(":"); + const hour = parseInt(hours, 10); + const min = minutes || "00"; + const period = hour >= 12 ? "PM" : "AM"; + const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + const hour12Padded = hour12.toString().padStart(2, "0"); + return `${hour12Padded}:${min} ${period}`; +}; + +// Format availability for display - groups days with same time range +const formatAvailabilityDisplay = ( + availability: Record +): string[] => { + const timeRangeMap: Record = {}; + + Object.keys(availability).forEach((dayIndexStr) => { + const dayIndex = Number(dayIndexStr); + const slots = availability[dayIndex]; + if (slots && slots.length > 0) { + slots.forEach((slot) => { + const timeKey = `${slot.startTime}-${slot.endTime}`; + if (!timeRangeMap[timeKey]) { + timeRangeMap[timeKey] = []; + } + timeRangeMap[timeKey].push(dayIndex); + }); + } + }); + + const formatted: string[] = []; + Object.keys(timeRangeMap).forEach((timeKey) => { + const days = timeRangeMap[timeKey].sort((a, b) => a - b); + const [startTime, endTime] = timeKey.split("-"); + const dayNames = days.map((day) => DAYS[day]).join(", "); + const timeRange = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`; + formatted.push(`${dayNames}, ${timeRange}`); + }); + + return formatted; +}; + +// Format date for display +const formatDateForDisplay = (dateStr: string): string => { + if (!dateStr) return ""; + const date = new Date(`${dateStr}T00:00:00`); + return date.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }); +}; + +export interface AvailabilityDetailScreenProps { + id: string; + onActionsReady?: (handlers: { handleSetAsDefault: () => void; handleDelete: () => void }) => void; +} + +export interface AvailabilityDetailScreenHandle { + setAsDefault: () => void; + delete: () => void; + refresh: () => void; +} + +export const AvailabilityDetailScreen = forwardRef< + AvailabilityDetailScreenHandle, + AvailabilityDetailScreenProps +>(function AvailabilityDetailScreen({ id, onActionsReady }, ref) { + const router = useRouter(); + const insets = useSafeAreaInsets(); + + const [loading, setLoading] = useState(true); + const [_schedule, setSchedule] = useState(null); + const [scheduleName, setScheduleName] = useState(""); + const [timeZone, setTimeZone] = useState(""); + const [isDefault, setIsDefault] = useState(false); + const [availability, setAvailability] = useState>({}); + const [overrides, setOverrides] = useState< + { + date: string; + startTime: string; + endTime: string; + }[] + >([]); + const [daysExpanded, setDaysExpanded] = useState(true); + const [overridesExpanded, setOverridesExpanded] = useState(true); + + const processScheduleData = useCallback( + (scheduleData: NonNullable>>) => { + const name = scheduleData.name ?? ""; + const tz = scheduleData.timeZone ?? "UTC"; + const isDefaultSchedule = scheduleData.isDefault ?? false; + + setSchedule(scheduleData); + setScheduleName(name); + setTimeZone(tz); + setIsDefault(isDefaultSchedule); + + const availabilityMap: Record = {}; + + const availabilityArray = scheduleData.availability; + if (availabilityArray && Array.isArray(availabilityArray)) { + availabilityArray.forEach((slot) => { + let days: number[] = []; + if (Array.isArray(slot.days)) { + days = slot.days + .map((day) => { + if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) { + return DAY_NAME_TO_NUMBER[day]; + } + if (typeof day === "string") { + const parsed = parseInt(day, 10); + if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) { + return parsed; + } + } + if (typeof day === "number" && day >= 0 && day <= 6) { + return day; + } + return null; + }) + .filter((day): day is number => day !== null); + } + + days.forEach((day) => { + if (!availabilityMap[day]) { + availabilityMap[day] = []; + } + const startTime = slot.startTime ?? "09:00:00"; + const endTime = slot.endTime ?? "17:00:00"; + availabilityMap[day].push({ + days: [day.toString()], + startTime, + endTime, + }); + }); + }); + } + + setAvailability(availabilityMap); + + const overridesArray = scheduleData.overrides; + if (overridesArray && Array.isArray(overridesArray)) { + const formattedOverrides = overridesArray.map((override) => { + const date = override.date ?? ""; + const startTime = override.startTime ?? "00:00"; + const endTime = override.endTime ?? "00:00"; + return { date, startTime, endTime }; + }); + setOverrides(formattedOverrides); + } else { + setOverrides([]); + } + }, + [] + ); + + const fetchSchedule = useCallback(async () => { + setLoading(true); + let scheduleData: Awaited> = null; + try { + scheduleData = await CalComAPIService.getScheduleById(Number(id)); + } catch (error) { + console.error("Error fetching schedule"); + if (__DEV__) { + const message = error instanceof Error ? error.message : String(error); + console.debug("[AvailabilityDetailScreen.ios] fetchSchedule failed", { + message, + }); + } + showErrorAlert("Error", "Failed to load availability. Please try again."); + router.back(); + setLoading(false); + return; + } + + if (scheduleData) { + processScheduleData(scheduleData); + } + setLoading(false); + }, [id, router, processScheduleData]); + + useEffect(() => { + if (id) { + fetchSchedule(); + } + }, [id, fetchSchedule]); + + const handleSetAsDefault = useCallback(async () => { + if (isDefault) { + Alert.alert("Info", "This schedule is already set as default"); + return; + } + + try { + await CalComAPIService.updateSchedule(Number(id), { + isDefault: true, + }); + setIsDefault(true); + Alert.alert("Success", "Availability set as default successfully"); + } catch { + showErrorAlert("Error", "Failed to set availability as default. Please try again."); + } + }, [isDefault, id]); + + const handleDelete = useCallback(() => { + if (isDefault) { + Alert.alert( + "Cannot Delete", + "You cannot delete the default schedule. Please set another schedule as default first." + ); + return; + } + + Alert.alert("Delete Availability", `Are you sure you want to delete "${scheduleName}"?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + await CalComAPIService.deleteSchedule(Number(id)); + Alert.alert("Success", "Availability deleted successfully", [ + { text: "OK", onPress: () => router.back() }, + ]); + } catch { + showErrorAlert("Error", "Failed to delete availability. Please try again."); + } + }, + }, + ]); + }, [isDefault, scheduleName, id, router]); + + // Expose handlers via ref + useImperativeHandle( + ref, + () => ({ + setAsDefault: handleSetAsDefault, + delete: handleDelete, + refresh: fetchSchedule, + }), + [handleSetAsDefault, handleDelete, fetchSchedule] + ); + + // Expose handlers to parent for iOS header menu + useEffect(() => { + if (onActionsReady) { + onActionsReady({ + handleSetAsDefault, + handleDelete, + }); + } + }, [onActionsReady, handleSetAsDefault, handleDelete]); + + // Count enabled days + const enabledDaysCount = Object.keys(availability).length; + + if (loading) { + return ( + + + Loading availability... + + ); + } + + return ( + + + {/* Schedule Title Section - iOS Calendar Style */} + + + + {scheduleName || "Untitled Schedule"} + + {isDefault && ( + + )} + + + + {/* Working Hours Summary Card */} + + + + Working Hours + + {Object.keys(availability).length > 0 ? ( + + {formatAvailabilityDisplay(availability).map((line) => ( + + {line} + + ))} + + ) : ( + No availability set + )} + + + + {/* Weekly Schedule Card - Expandable */} + + setDaysExpanded(!daysExpanded)} + > + Weekly Schedule + + + {enabledDaysCount} {enabledDaysCount === 1 ? "day" : "days"} + + + + + + {daysExpanded && ( + + {DAYS.map((day, dayIndex) => { + const daySlots = availability[dayIndex] || []; + const isEnabled = daySlots.length > 0; + + return ( + 0 ? "border-t border-[#E5E5EA]" : "" + }`} + > + + + {DAYS[dayIndex]} + + + {isEnabled ? ( + + {daySlots.map((slot, slotIndex) => ( + 0 ? "mt-1" : ""}`} + > + {formatTime12Hour(slot.startTime)} – {formatTime12Hour(slot.endTime)} + + ))} + + ) : ( + + Unavailable + + )} + + ); + })} + + )} + + + {/* Timezone Card */} + + + Timezone + {timeZone} + + + + {/* Date Overrides Card - Expandable */} + {overrides.length > 0 && ( + + setOverridesExpanded(!overridesExpanded)} + > + Date Overrides + + + {overrides.length} {overrides.length === 1 ? "override" : "overrides"} + + + + + + {overridesExpanded && ( + + {overrides.map((override, index) => ( + 0 ? "border-t border-[#E5E5EA]" : ""}`} + > + + {formatDateForDisplay(override.date)} + + {override.startTime === "00:00" && override.endTime === "00:00" ? ( + Unavailable + ) : ( + + {formatTime12Hour(`${override.startTime}:00`)} –{" "} + {formatTime12Hour(`${override.endTime}:00`)} + + )} + + ))} + + )} + + )} + + {/* No Overrides Message */} + {overrides.length === 0 && ( + + + Date Overrides + No date overrides set + + + )} + + + ); +}); diff --git a/companion/components/screens/AvailabilityDetailScreen.tsx b/companion/components/screens/AvailabilityDetailScreen.tsx index 723e5e0f1cb59f..745d564d1edacb 100644 --- a/companion/components/screens/AvailabilityDetailScreen.tsx +++ b/companion/components/screens/AvailabilityDetailScreen.tsx @@ -6,6 +6,7 @@ import { ActivityIndicator, Alert, ScrollView, Switch, Text, TextInput, View } f import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppPressable } from "@/components/AppPressable"; import { FullScreenModal } from "@/components/FullScreenModal"; +import { TIMEZONES } from "@/constants/timezones"; import { CalComAPIService, type Schedule } from "@/services/calcom"; import type { ScheduleAvailability } from "@/services/types"; import { showErrorAlert } from "@/utils/alerts"; @@ -92,6 +93,9 @@ export interface AvailabilityDetailScreenProps { export interface AvailabilityDetailScreenHandle { save: () => void; + setAsDefault: () => void; + delete: () => void; + refresh: () => void; } export const AvailabilityDetailScreen = forwardRef< @@ -131,21 +135,8 @@ export const AvailabilityDetailScreen = forwardRef< type: "start" | "end"; } | null>(null); - // Common timezones - const timezones = [ - "America/New_York", - "America/Chicago", - "America/Denver", - "America/Los_Angeles", - "Europe/London", - "Europe/Paris", - "Europe/Berlin", - "Asia/Tokyo", - "Asia/Shanghai", - "Asia/Kolkata", - "Australia/Sydney", - "UTC", - ]; + // Use all supported timezones from the shared constants + const timezones = TIMEZONES; const processScheduleData = useCallback( (scheduleData: NonNullable>>) => { @@ -237,7 +228,7 @@ export const AvailabilityDetailScreen = forwardRef< message, }); } - showErrorAlert("Error", "Failed to load schedule. Please try again."); + showErrorAlert("Error", "Failed to load availability. Please try again."); router.back(); setLoading(false); return; @@ -325,6 +316,12 @@ export const AvailabilityDetailScreen = forwardRef< }; const handleSave = async () => { + // Validate availability name + if (!scheduleName.trim()) { + Alert.alert("Error", "Please enter an availability name"); + return; + } + setSaving(true); try { // Convert availability object back to array format with day names @@ -364,42 +361,37 @@ export const AvailabilityDetailScreen = forwardRef< })); await CalComAPIService.updateSchedule(Number(id), { - name: scheduleName, + name: scheduleName.trim(), timeZone, availability: availabilityArray, isDefault: isDefault, overrides: formattedOverrides, }); - Alert.alert("Success", "Schedule updated successfully", [ + Alert.alert("Success", "Availability updated successfully", [ { text: "OK", onPress: () => router.back() }, ]); setSaving(false); } catch { - showErrorAlert("Error", "Failed to update schedule. Please try again."); + showErrorAlert("Error", "Failed to update availability. Please try again."); setSaving(false); } }; - // Expose save method to parent via ref - useImperativeHandle(ref, () => ({ - save: handleSave, - })); - const handleSetAsDefault = async () => { try { await CalComAPIService.updateSchedule(Number(id), { isDefault: true, }); setIsDefault(true); - Alert.alert("Success", "Schedule set as default successfully"); + Alert.alert("Success", "Availability set as default successfully"); } catch { - showErrorAlert("Error", "Failed to set schedule as default. Please try again."); + showErrorAlert("Error", "Failed to set availability as default. Please try again."); } }; const handleDelete = () => { - Alert.alert("Delete Schedule", `Are you sure you want to delete "${scheduleName}"?`, [ + Alert.alert("Delete Availability", `Are you sure you want to delete "${scheduleName}"?`, [ { text: "Cancel", style: "cancel" }, { text: "Delete", @@ -407,17 +399,25 @@ export const AvailabilityDetailScreen = forwardRef< onPress: async () => { try { await CalComAPIService.deleteSchedule(Number(id)); - Alert.alert("Success", "Schedule deleted successfully", [ + Alert.alert("Success", "Availability deleted successfully", [ { text: "OK", onPress: () => router.back() }, ]); } catch { - showErrorAlert("Error", "Failed to delete schedule. Please try again."); + showErrorAlert("Error", "Failed to delete availability. Please try again."); } }, }, ]); }; + // Expose methods to parent via ref + useImperativeHandle(ref, () => ({ + save: handleSave, + setAsDefault: handleSetAsDefault, + delete: handleDelete, + refresh: fetchSchedule, + })); + const handleAddOverride = () => { setEditingOverride(null); setOverrideDate(""); @@ -489,7 +489,7 @@ export const AvailabilityDetailScreen = forwardRef< return ( - Loading schedule... + Loading availability... ); } @@ -547,9 +547,21 @@ export const AvailabilityDetailScreen = forwardRef< contentContainerStyle={{ padding: 16, paddingBottom: 200 }} > - {/* Schedule Name and Working Hours Display */} + {/* Availability Name */} + + Availability Name + + + + {/* Working Hours Display */} - {scheduleName} + Working Hours {Object.keys(availability).length > 0 ? ( {formatAvailabilityDisplay(availability).map((line) => ( diff --git a/companion/components/screens/AvailabilityListScreen.tsx b/companion/components/screens/AvailabilityListScreen.tsx index ab00cbc1661d5a..d81a86fb78b280 100644 --- a/companion/components/screens/AvailabilityListScreen.tsx +++ b/companion/components/screens/AvailabilityListScreen.tsx @@ -230,7 +230,7 @@ export function AvailabilityListScreen({ const handleSchedulePress = (schedule: Schedule) => { router.push({ - pathname: "/availability-detail", + pathname: "/(tabs)/(availability)/availability-detail", params: { id: schedule.id.toString() }, }); }; @@ -287,7 +287,7 @@ export function AvailabilityListScreen({ // Navigate to edit the newly created schedule router.push({ - pathname: "/availability-detail", + pathname: "/(tabs)/(availability)/availability-detail", params: { id: newSchedule.id.toString(), }, diff --git a/companion/components/screens/EditAvailabilityDayScreen.ios.tsx b/companion/components/screens/EditAvailabilityDayScreen.ios.tsx new file mode 100644 index 00000000000000..c5df9757de01d2 --- /dev/null +++ b/companion/components/screens/EditAvailabilityDayScreen.ios.tsx @@ -0,0 +1,423 @@ +import { DatePicker, Host } from "@expo/ui/swift-ui"; +import { Ionicons } from "@expo/vector-icons"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react"; +import { Alert, ScrollView, Switch, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AppPressable } from "@/components/AppPressable"; +import type { Schedule } from "@/services/calcom"; +import { CalComAPIService } from "@/services/calcom"; +import type { ScheduleAvailability } from "@/services/types"; +import { showErrorAlert } from "@/utils/alerts"; + +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +// Map day names to numbers +const DAY_NAME_TO_NUMBER: Record = { + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, +}; + +// Parse time string to Date object (for DatePicker) +const timeStringToDate = (timeStr: string): Date => { + const [hours, minutes] = timeStr.split(":"); + const date = new Date(); + date.setHours(parseInt(hours, 10)); + date.setMinutes(parseInt(minutes || "0", 10)); + date.setSeconds(0); + date.setMilliseconds(0); + return date; +}; + +// Convert Date to time string (HH:mm) +const dateToTimeString = (date: Date): string => { + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + return `${hours}:${minutes}`; +}; + +// Parse availability from schedule for a specific day +const parseAvailabilityForDay = ( + schedule: Schedule | null, + dayIndex: number +): ScheduleAvailability[] => { + if (!schedule) return []; + + const slots: ScheduleAvailability[] = []; + const availabilityArray = schedule.availability; + + if (availabilityArray && Array.isArray(availabilityArray)) { + availabilityArray.forEach((slot) => { + let days: number[] = []; + if (Array.isArray(slot.days)) { + days = slot.days + .map((day) => { + if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) { + return DAY_NAME_TO_NUMBER[day]; + } + if (typeof day === "string") { + const parsed = parseInt(day, 10); + if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) { + return parsed; + } + } + if (typeof day === "number" && day >= 0 && day <= 6) { + return day; + } + return null; + }) + .filter((day): day is number => day !== null); + } + + if (days.includes(dayIndex)) { + const startTime = slot.startTime ?? "09:00:00"; + const endTime = slot.endTime ?? "17:00:00"; + slots.push({ + days: [dayIndex.toString()], + startTime, + endTime, + }); + } + }); + } + + return slots; +}; + +// Build full availability array from schedule, replacing the specific day +const buildFullAvailability = ( + schedule: Schedule, + dayIndex: number, + newSlots: ScheduleAvailability[] +): { days: string[]; startTime: string; endTime: string }[] => { + const result: { days: string[]; startTime: string; endTime: string }[] = []; + + // First, add all existing slots except for the target day + const existingArray = schedule.availability; + if (existingArray && Array.isArray(existingArray)) { + existingArray.forEach((slot) => { + let days: number[] = []; + if (Array.isArray(slot.days)) { + days = slot.days + .map((day) => { + if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) { + return DAY_NAME_TO_NUMBER[day]; + } + if (typeof day === "string") { + const parsed = parseInt(day, 10); + if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) { + return parsed; + } + } + if (typeof day === "number" && day >= 0 && day <= 6) { + return day; + } + return null; + }) + .filter((day): day is number => day !== null); + } + + // Remove the target day from this slot's days + const otherDays = days.filter((d) => d !== dayIndex); + if (otherDays.length > 0) { + otherDays.forEach((d) => { + result.push({ + days: [DAYS[d]], + startTime: (slot.startTime ?? "09:00:00").substring(0, 5), + endTime: (slot.endTime ?? "17:00:00").substring(0, 5), + }); + }); + } + }); + } + + // Add the new slots for the target day + newSlots.forEach((slot) => { + result.push({ + days: [DAYS[dayIndex]], + startTime: slot.startTime.substring(0, 5), + endTime: slot.endTime.substring(0, 5), + }); + }); + + return result; +}; + +export interface EditAvailabilityDayScreenProps { + schedule: Schedule | null; + dayIndex: number; + onSuccess: () => void; + onSavingChange?: (isSaving: boolean) => void; + transparentBackground?: boolean; +} + +export interface EditAvailabilityDayScreenHandle { + submit: () => void; +} + +export const EditAvailabilityDayScreen = forwardRef< + EditAvailabilityDayScreenHandle, + EditAvailabilityDayScreenProps +>(function EditAvailabilityDayScreen( + { schedule, dayIndex, onSuccess, onSavingChange, transparentBackground = false }, + ref +) { + const insets = useSafeAreaInsets(); + const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]"; + + const [isEnabled, setIsEnabled] = useState(false); + const [slots, setSlots] = useState<{ startTime: Date; endTime: Date }[]>([]); + const [isSaving, setIsSaving] = useState(false); + + const dayName = DAYS[dayIndex] || "Day"; + + // Initialize from schedule + useEffect(() => { + if (schedule) { + const daySlots = parseAvailabilityForDay(schedule, dayIndex); + if (daySlots.length > 0) { + setIsEnabled(true); + setSlots( + daySlots.map((s) => ({ + startTime: timeStringToDate(s.startTime.substring(0, 5)), + endTime: timeStringToDate(s.endTime.substring(0, 5)), + })) + ); + } else { + setIsEnabled(false); + setSlots([ + { + startTime: timeStringToDate("09:00"), + endTime: timeStringToDate("17:00"), + }, + ]); + } + } + }, [schedule, dayIndex]); + + // Notify parent of saving state + useEffect(() => { + onSavingChange?.(isSaving); + }, [isSaving, onSavingChange]); + + const handleToggle = useCallback( + (value: boolean) => { + setIsEnabled(value); + if (value && slots.length === 0) { + setSlots([ + { + startTime: timeStringToDate("09:00"), + endTime: timeStringToDate("17:00"), + }, + ]); + } + }, + [slots.length] + ); + + const handleAddSlot = useCallback(() => { + setSlots((prev) => [ + ...prev, + { + startTime: timeStringToDate("09:00"), + endTime: timeStringToDate("17:00"), + }, + ]); + }, []); + + const handleRemoveSlot = useCallback((index: number) => { + setSlots((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const handleStartTimeChange = useCallback((index: number, date: Date) => { + setSlots((prev) => { + const newSlots = [...prev]; + newSlots[index] = { ...newSlots[index], startTime: date }; + return newSlots; + }); + }, []); + + const handleEndTimeChange = useCallback((index: number, date: Date) => { + setSlots((prev) => { + const newSlots = [...prev]; + newSlots[index] = { ...newSlots[index], endTime: date }; + return newSlots; + }); + }, []); + + const handleSubmit = useCallback(async () => { + if (!schedule || isSaving) return; + + // Validate all slots have end time after start time + // Compare time strings to avoid issues with Date object day components + if (isEnabled) { + for (const slot of slots) { + const startTimeStr = dateToTimeString(slot.startTime); + const endTimeStr = dateToTimeString(slot.endTime); + if (endTimeStr <= startTimeStr) { + Alert.alert("Error", "End time must be after start time for all slots"); + return; + } + } + } + + // Build availability slots for this day + const daySlots: ScheduleAvailability[] = isEnabled + ? slots.map((s) => ({ + days: [dayIndex.toString()], + startTime: `${dateToTimeString(s.startTime)}:00`, + endTime: `${dateToTimeString(s.endTime)}:00`, + })) + : []; + + const fullAvailability = buildFullAvailability(schedule, dayIndex, daySlots); + + setIsSaving(true); + try { + await CalComAPIService.updateSchedule(schedule.id, { + availability: fullAvailability, + }); + Alert.alert("Success", `${dayName} updated successfully`, [ + { text: "OK", onPress: onSuccess }, + ]); + setIsSaving(false); + } catch { + showErrorAlert("Error", "Failed to update schedule. Please try again."); + setIsSaving(false); + } + }, [schedule, dayIndex, dayName, isEnabled, slots, onSuccess, isSaving]); + + // Expose submit to parent via ref + useImperativeHandle( + ref, + () => ({ + submit: handleSubmit, + }), + [handleSubmit] + ); + + if (!schedule) { + return ( + + No schedule data + + ); + } + + return ( + + {/* Enable/Disable Toggle */} + + Available on {dayName} + + + + {/* Time Slots */} + {isEnabled && ( + <> + Time Slots + + {slots.map((slot, index) => ( + + + Slot {index + 1} + {slots.length > 1 && ( + handleRemoveSlot(index)} className="p-1"> + + + )} + + + + + Start + + + handleStartTimeChange(index, date)} + displayedComponents={["hourAndMinute"]} + selection={slot.startTime} + /> + + + + + + + + End + + + handleEndTimeChange(index, date)} + displayedComponents={["hourAndMinute"]} + selection={slot.endTime} + /> + + + + + + ))} + + {/* Add Slot Button */} + + + Add Time Slot + + + )} + + {/* Unavailable Message */} + {!isEnabled && ( + + + + You are unavailable on {dayName} + + + Toggle on to set availability + + + )} + + ); +}); + +export default EditAvailabilityDayScreen; diff --git a/companion/components/screens/EditAvailabilityDayScreen.tsx b/companion/components/screens/EditAvailabilityDayScreen.tsx new file mode 100644 index 00000000000000..c5c15a881a97a9 --- /dev/null +++ b/companion/components/screens/EditAvailabilityDayScreen.tsx @@ -0,0 +1,438 @@ +import { Ionicons } from "@expo/vector-icons"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react"; +import { Alert, ScrollView, Switch, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AppPressable } from "@/components/AppPressable"; +import { FullScreenModal } from "@/components/FullScreenModal"; +import type { Schedule } from "@/services/calcom"; +import { CalComAPIService } from "@/services/calcom"; +import type { ScheduleAvailability } from "@/services/types"; +import { showErrorAlert } from "@/utils/alerts"; + +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +// Map day names to numbers +const DAY_NAME_TO_NUMBER: Record = { + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, +}; + +// Generate time options (15-minute intervals) +const generateTimeOptions = () => { + const options: string[] = []; + for (let hour = 0; hour < 24; hour++) { + for (let minute = 0; minute < 60; minute += 15) { + const h = hour.toString().padStart(2, "0"); + const m = minute.toString().padStart(2, "0"); + options.push(`${h}:${m}`); + } + } + return options; +}; + +const TIME_OPTIONS = generateTimeOptions(); + +// Convert 24-hour time to 12-hour format with AM/PM +const formatTime12Hour = (time24: string): string => { + const [hours, minutes] = time24.split(":"); + const hour = parseInt(hours, 10); + const min = minutes || "00"; + const period = hour >= 12 ? "PM" : "AM"; + const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + return `${hour12}:${min} ${period}`; +}; + +// Parse availability from schedule for a specific day +const parseAvailabilityForDay = ( + schedule: Schedule | null, + dayIndex: number +): ScheduleAvailability[] => { + if (!schedule) return []; + + const slots: ScheduleAvailability[] = []; + const availabilityArray = schedule.availability; + + if (availabilityArray && Array.isArray(availabilityArray)) { + availabilityArray.forEach((slot) => { + let days: number[] = []; + if (Array.isArray(slot.days)) { + days = slot.days + .map((day) => { + if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) { + return DAY_NAME_TO_NUMBER[day]; + } + if (typeof day === "string") { + const parsed = parseInt(day, 10); + if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) { + return parsed; + } + } + if (typeof day === "number" && day >= 0 && day <= 6) { + return day; + } + return null; + }) + .filter((day): day is number => day !== null); + } + + if (days.includes(dayIndex)) { + const startTime = slot.startTime ?? "09:00:00"; + const endTime = slot.endTime ?? "17:00:00"; + slots.push({ + days: [dayIndex.toString()], + startTime, + endTime, + }); + } + }); + } + + return slots; +}; + +// Build full availability array from schedule, replacing the specific day +const buildFullAvailability = ( + schedule: Schedule, + dayIndex: number, + newSlots: ScheduleAvailability[] +): { days: string[]; startTime: string; endTime: string }[] => { + const result: { days: string[]; startTime: string; endTime: string }[] = []; + + const existingArray = schedule.availability; + if (existingArray && Array.isArray(existingArray)) { + existingArray.forEach((slot) => { + let days: number[] = []; + if (Array.isArray(slot.days)) { + days = slot.days + .map((day) => { + if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) { + return DAY_NAME_TO_NUMBER[day]; + } + if (typeof day === "string") { + const parsed = parseInt(day, 10); + if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) { + return parsed; + } + } + if (typeof day === "number" && day >= 0 && day <= 6) { + return day; + } + return null; + }) + .filter((day): day is number => day !== null); + } + + const otherDays = days.filter((d) => d !== dayIndex); + if (otherDays.length > 0) { + otherDays.forEach((d) => { + result.push({ + days: [DAYS[d]], + startTime: (slot.startTime ?? "09:00:00").substring(0, 5), + endTime: (slot.endTime ?? "17:00:00").substring(0, 5), + }); + }); + } + }); + } + + newSlots.forEach((slot) => { + result.push({ + days: [DAYS[dayIndex]], + startTime: slot.startTime.substring(0, 5), + endTime: slot.endTime.substring(0, 5), + }); + }); + + return result; +}; + +export interface EditAvailabilityDayScreenProps { + schedule: Schedule | null; + dayIndex: number; + onSuccess: () => void; + onSavingChange?: (isSaving: boolean) => void; + transparentBackground?: boolean; +} + +export interface EditAvailabilityDayScreenHandle { + submit: () => void; +} + +export const EditAvailabilityDayScreen = forwardRef< + EditAvailabilityDayScreenHandle, + EditAvailabilityDayScreenProps +>(function EditAvailabilityDayScreen({ schedule, dayIndex, onSuccess, onSavingChange }, ref) { + const insets = useSafeAreaInsets(); + + const [isEnabled, setIsEnabled] = useState(false); + const [slots, setSlots] = useState<{ startTime: string; endTime: string }[]>([]); + const [isSaving, setIsSaving] = useState(false); + const [showTimePicker, setShowTimePicker] = useState<{ + slotIndex: number; + type: "start" | "end"; + } | null>(null); + + const dayName = DAYS[dayIndex] || "Day"; + + // Initialize from schedule + useEffect(() => { + if (schedule) { + const daySlots = parseAvailabilityForDay(schedule, dayIndex); + if (daySlots.length > 0) { + setIsEnabled(true); + setSlots( + daySlots.map((s) => ({ + startTime: s.startTime.substring(0, 5), + endTime: s.endTime.substring(0, 5), + })) + ); + } else { + setIsEnabled(false); + setSlots([{ startTime: "09:00", endTime: "17:00" }]); + } + } + }, [schedule, dayIndex]); + + // Notify parent of saving state + useEffect(() => { + onSavingChange?.(isSaving); + }, [isSaving, onSavingChange]); + + const handleToggle = useCallback( + (value: boolean) => { + setIsEnabled(value); + if (value && slots.length === 0) { + setSlots([{ startTime: "09:00", endTime: "17:00" }]); + } + }, + [slots.length] + ); + + const handleAddSlot = useCallback(() => { + setSlots((prev) => [...prev, { startTime: "09:00", endTime: "17:00" }]); + }, []); + + const handleRemoveSlot = useCallback((index: number) => { + setSlots((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const handleTimeSelect = useCallback( + (time: string) => { + if (!showTimePicker) return; + + setSlots((prev) => { + const newSlots = [...prev]; + if (showTimePicker.type === "start") { + newSlots[showTimePicker.slotIndex] = { + ...newSlots[showTimePicker.slotIndex], + startTime: time, + }; + } else { + newSlots[showTimePicker.slotIndex] = { + ...newSlots[showTimePicker.slotIndex], + endTime: time, + }; + } + return newSlots; + }); + setShowTimePicker(null); + }, + [showTimePicker] + ); + + const handleSubmit = useCallback(async () => { + if (!schedule || isSaving) return; + + // Validate all slots have end time after start time + if (isEnabled) { + for (const slot of slots) { + if (slot.endTime <= slot.startTime) { + Alert.alert("Error", "End time must be after start time for all slots"); + return; + } + } + } + + const daySlots: ScheduleAvailability[] = isEnabled + ? slots.map((s) => ({ + days: [dayIndex.toString()], + startTime: `${s.startTime}:00`, + endTime: `${s.endTime}:00`, + })) + : []; + + const fullAvailability = buildFullAvailability(schedule, dayIndex, daySlots); + + setIsSaving(true); + try { + await CalComAPIService.updateSchedule(schedule.id, { + availability: fullAvailability, + }); + Alert.alert("Success", `${dayName} updated successfully`, [ + { text: "OK", onPress: onSuccess }, + ]); + setIsSaving(false); + } catch { + showErrorAlert("Error", "Failed to update schedule. Please try again."); + setIsSaving(false); + } + }, [schedule, dayIndex, dayName, isEnabled, slots, onSuccess, isSaving]); + + useImperativeHandle( + ref, + () => ({ + submit: handleSubmit, + }), + [handleSubmit] + ); + + if (!schedule) { + return ( + + No schedule data + + ); + } + + return ( + + {/* Enable/Disable Toggle */} + + Available on {dayName} + + + + {/* Time Slots */} + {isEnabled && ( + <> + Time Slots + + {slots.map((slot, index) => ( + + + Slot {index + 1} + {slots.length > 1 && ( + handleRemoveSlot(index)} className="p-1"> + + + )} + + + + setShowTimePicker({ slotIndex: index, type: "start" })} + > + Start + {formatTime12Hour(slot.startTime)} + + + + + setShowTimePicker({ slotIndex: index, type: "end" })} + > + End + {formatTime12Hour(slot.endTime)} + + + + ))} + + {/* Add Slot Button */} + + + Add Time Slot + + + )} + + {/* Unavailable Message */} + {!isEnabled && ( + + + + You are unavailable on {dayName} + + + Toggle on to set availability + + + )} + + {/* Time Picker Modal */} + setShowTimePicker(null)} + > + + + + Select {showTimePicker?.type === "start" ? "Start" : "End"} Time + + setShowTimePicker(null)}> + + + + + {TIME_OPTIONS.map((time) => { + const slotIndex = showTimePicker?.slotIndex ?? 0; + const currentTime = + showTimePicker?.type === "start" + ? slots[slotIndex]?.startTime + : slots[slotIndex]?.endTime; + const isSelected = currentTime === time; + + return ( + handleTimeSelect(time)} + className={`border-b border-gray-100 px-4 py-3.5 ${ + isSelected ? "bg-blue-50" : "" + }`} + > + + + {formatTime12Hour(time)} + + {isSelected && } + + + ); + })} + + + + + ); +}); + +export default EditAvailabilityDayScreen; diff --git a/companion/components/screens/EditAvailabilityHoursScreen.ios.tsx b/companion/components/screens/EditAvailabilityHoursScreen.ios.tsx new file mode 100644 index 00000000000000..6f6dde4fc7ac68 --- /dev/null +++ b/companion/components/screens/EditAvailabilityHoursScreen.ios.tsx @@ -0,0 +1,178 @@ +import { Ionicons } from "@expo/vector-icons"; +import { forwardRef } from "react"; +import { ScrollView, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AppPressable } from "@/components/AppPressable"; +import type { Schedule } from "@/services/calcom"; +import type { ScheduleAvailability } from "@/services/types"; + +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +// Map day names to numbers +const DAY_NAME_TO_NUMBER: Record = { + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, +}; + +// Convert 24-hour time to 12-hour format with AM/PM +const formatTime12Hour = (time24: string): string => { + const [hours, minutes] = time24.split(":"); + const hour = parseInt(hours, 10); + const min = minutes || "00"; + const period = hour >= 12 ? "PM" : "AM"; + const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + const hour12Padded = hour12.toString().padStart(2, "0"); + return `${hour12Padded}:${min} ${period}`; +}; + +// Parse availability from schedule +const parseAvailability = (schedule: Schedule | null): Record => { + if (!schedule) return {}; + + const availabilityMap: Record = {}; + const availabilityArray = schedule.availability; + + if (availabilityArray && Array.isArray(availabilityArray)) { + availabilityArray.forEach((slot) => { + let days: number[] = []; + if (Array.isArray(slot.days)) { + days = slot.days + .map((day) => { + if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) { + return DAY_NAME_TO_NUMBER[day]; + } + if (typeof day === "string") { + const parsed = parseInt(day, 10); + if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) { + return parsed; + } + } + if (typeof day === "number" && day >= 0 && day <= 6) { + return day; + } + return null; + }) + .filter((day): day is number => day !== null); + } + + days.forEach((day) => { + if (!availabilityMap[day]) { + availabilityMap[day] = []; + } + const startTime = slot.startTime ?? "09:00:00"; + const endTime = slot.endTime ?? "17:00:00"; + availabilityMap[day].push({ + days: [day.toString()], + startTime, + endTime, + }); + }); + }); + } + + return availabilityMap; +}; + +export interface EditAvailabilityHoursScreenProps { + schedule: Schedule | null; + onDayPress: (dayIndex: number) => void; + transparentBackground?: boolean; +} + +export const EditAvailabilityHoursScreen = forwardRef( + function EditAvailabilityHoursScreen( + { schedule, onDayPress, transparentBackground = false }, + _ref + ) { + const insets = useSafeAreaInsets(); + const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]"; + + const availability = parseAvailability(schedule); + + if (!schedule) { + return ( + + No schedule data + + ); + } + + return ( + + + Tap a day to edit its hours + + + + {DAYS.map((day, dayIndex) => { + const daySlots = availability[dayIndex] || []; + const isEnabled = daySlots.length > 0; + + return ( + 0 ? "border-t border-[#E5E5EA]" : "" + }`} + onPress={() => onDayPress(dayIndex)} + > + {/* Day status indicator */} + + + {/* Day name */} + + {day} + + + {/* Time slots or unavailable */} + + {isEnabled ? ( + daySlots.map((slot, slotIndex) => ( + 0 ? "mt-0.5" : ""}`} + > + {formatTime12Hour(slot.startTime)} – {formatTime12Hour(slot.endTime)} + + )) + ) : ( + Unavailable + )} + + + {/* Chevron */} + + + ); + })} + + + ); + } +); + +export default EditAvailabilityHoursScreen; diff --git a/companion/components/screens/EditAvailabilityHoursScreen.tsx b/companion/components/screens/EditAvailabilityHoursScreen.tsx new file mode 100644 index 00000000000000..3e3d3b8f727ae1 --- /dev/null +++ b/companion/components/screens/EditAvailabilityHoursScreen.tsx @@ -0,0 +1,169 @@ +import { Ionicons } from "@expo/vector-icons"; +import { forwardRef } from "react"; +import { ScrollView, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AppPressable } from "@/components/AppPressable"; +import type { Schedule } from "@/services/calcom"; +import type { ScheduleAvailability } from "@/services/types"; + +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +// Map day names to numbers +const DAY_NAME_TO_NUMBER: Record = { + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, +}; + +// Convert 24-hour time to 12-hour format with AM/PM +const formatTime12Hour = (time24: string): string => { + const [hours, minutes] = time24.split(":"); + const hour = parseInt(hours, 10); + const min = minutes || "00"; + const period = hour >= 12 ? "PM" : "AM"; + const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + const hour12Padded = hour12.toString().padStart(2, "0"); + return `${hour12Padded}:${min} ${period}`; +}; + +// Parse availability from schedule +const parseAvailability = (schedule: Schedule | null): Record => { + if (!schedule) return {}; + + const availabilityMap: Record = {}; + const availabilityArray = schedule.availability; + + if (availabilityArray && Array.isArray(availabilityArray)) { + availabilityArray.forEach((slot) => { + let days: number[] = []; + if (Array.isArray(slot.days)) { + days = slot.days + .map((day) => { + if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) { + return DAY_NAME_TO_NUMBER[day]; + } + if (typeof day === "string") { + const parsed = parseInt(day, 10); + if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) { + return parsed; + } + } + if (typeof day === "number" && day >= 0 && day <= 6) { + return day; + } + return null; + }) + .filter((day): day is number => day !== null); + } + + days.forEach((day) => { + if (!availabilityMap[day]) { + availabilityMap[day] = []; + } + const startTime = slot.startTime ?? "09:00:00"; + const endTime = slot.endTime ?? "17:00:00"; + availabilityMap[day].push({ + days: [day.toString()], + startTime, + endTime, + }); + }); + }); + } + + return availabilityMap; +}; + +export interface EditAvailabilityHoursScreenProps { + schedule: Schedule | null; + onDayPress: (dayIndex: number) => void; + transparentBackground?: boolean; +} + +export const EditAvailabilityHoursScreen = forwardRef( + function EditAvailabilityHoursScreen({ schedule, onDayPress }, _ref) { + const insets = useSafeAreaInsets(); + + const availability = parseAvailability(schedule); + + if (!schedule) { + return ( + + No schedule data + + ); + } + + return ( + + + Tap a day to edit its hours + + + + {DAYS.map((day, dayIndex) => { + const daySlots = availability[dayIndex] || []; + const isEnabled = daySlots.length > 0; + + return ( + 0 ? "border-t border-gray-200" : "" + }`} + onPress={() => onDayPress(dayIndex)} + > + {/* Day status indicator */} + + + {/* Day name */} + + {day} + + + {/* Time slots or unavailable */} + + {isEnabled ? ( + daySlots.map((slot, slotIndex) => ( + 0 ? "mt-0.5" : ""}`} + > + {formatTime12Hour(slot.startTime)} – {formatTime12Hour(slot.endTime)} + + )) + ) : ( + Unavailable + )} + + + {/* Chevron */} + + + ); + })} + + + ); + } +); + +export default EditAvailabilityHoursScreen; diff --git a/companion/components/screens/EditAvailabilityNameScreen.ios.tsx b/companion/components/screens/EditAvailabilityNameScreen.ios.tsx new file mode 100644 index 00000000000000..d2c5992c0a7041 --- /dev/null +++ b/companion/components/screens/EditAvailabilityNameScreen.ios.tsx @@ -0,0 +1,244 @@ +import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui"; +import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers"; +import { Ionicons } from "@expo/vector-icons"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react"; +import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { TIMEZONES as ALL_TIMEZONES } from "@/constants/timezones"; +import type { Schedule } from "@/services/calcom"; +import { CalComAPIService } from "@/services/calcom"; +import { showErrorAlert } from "@/utils/alerts"; + +// Format timezones for display +const TIMEZONES = ALL_TIMEZONES.map((tz) => ({ + id: tz, + label: tz.replace(/_/g, " "), +})); + +export interface EditAvailabilityNameScreenProps { + schedule: Schedule | null; + onSuccess: () => void; + onSavingChange?: (isSaving: boolean) => void; + transparentBackground?: boolean; +} + +export interface EditAvailabilityNameScreenHandle { + submit: () => void; +} + +export const EditAvailabilityNameScreen = forwardRef< + EditAvailabilityNameScreenHandle, + EditAvailabilityNameScreenProps +>(function EditAvailabilityNameScreen( + { schedule, onSuccess, onSavingChange, transparentBackground = false }, + ref +) { + const insets = useSafeAreaInsets(); + const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]"; + + const [name, setName] = useState(""); + const [timezone, setTimezone] = useState("UTC"); + const [isSaving, setIsSaving] = useState(false); + + // Initialize from schedule + useEffect(() => { + if (schedule) { + setName(schedule.name ?? ""); + setTimezone(schedule.timeZone ?? "UTC"); + } + }, [schedule]); + + // Notify parent of saving state + useEffect(() => { + onSavingChange?.(isSaving); + }, [isSaving, onSavingChange]); + + const handleTimezoneSelect = useCallback((tz: string) => { + setTimezone(tz); + }, []); + + const handleSubmit = useCallback(async () => { + if (!schedule || isSaving) return; + + const trimmedName = name.trim(); + if (!trimmedName) { + Alert.alert("Error", "Please enter a schedule name"); + return; + } + + setIsSaving(true); + try { + await CalComAPIService.updateSchedule(schedule.id, { + name: trimmedName, + timeZone: timezone, + }); + Alert.alert("Success", "Schedule updated successfully", [{ text: "OK", onPress: onSuccess }]); + setIsSaving(false); + } catch { + showErrorAlert("Error", "Failed to update schedule. Please try again."); + setIsSaving(false); + } + }, [schedule, name, timezone, onSuccess, isSaving]); + + // Expose submit to parent via ref + useImperativeHandle( + ref, + () => ({ + submit: handleSubmit, + }), + [handleSubmit] + ); + + const selectedTimezoneLabel = TIMEZONES.find((tz) => tz.id === timezone)?.label || timezone; + + if (!schedule) { + return ( + + No schedule data + + ); + } + + return ( + + + {transparentBackground ? ( + <> + {/* Name Input - Glass UI */} + Schedule Name + + + + + {/* Timezone Selector - Glass UI */} + Timezone + + + + + + {selectedTimezoneLabel} + {timezone} + + + {/* Native iOS Context Menu Button */} + + + + {TIMEZONES.map((tz) => ( +