diff --git a/companion/app/(tabs)/(event-types)/event-type-detail.tsx b/companion/app/(tabs)/(event-types)/event-type-detail.tsx index b95efcffea..fd1b7baea0 100644 --- a/companion/app/(tabs)/(event-types)/event-type-detail.tsx +++ b/companion/app/(tabs)/(event-types)/event-type-detail.tsx @@ -163,15 +163,15 @@ export default function EventTypeDetail() { const [showScheduleDropdown, setShowScheduleDropdown] = useState(false); const [schedulesLoading, setSchedulesLoading] = useState(false); const [scheduleDetailsLoading, setScheduleDetailsLoading] = useState(false); - const [initialScheduleId, setInitialScheduleId] = useState(null); - const hasAutoSelectedScheduleRef = useRef(false); + const [initialScheduleId, setInitialScheduleId] = useState(null); + const hasAutoSelectedScheduleRef = useRef(false); - // Reset auto-selection ref when event type id changes (e.g., navigation reuse) - useEffect(() => { - hasAutoSelectedScheduleRef.current = false; - }, [id]); + // Reset auto-selection ref when event type id changes (e.g., navigation reuse) + useEffect(() => { + hasAutoSelectedScheduleRef.current = false; + }, []); - const [isHidden, setIsHidden] = useState(false); + const [isHidden, setIsHidden] = useState(false); const [selectedTimezone, setSelectedTimezone] = useState(""); const [showTimezoneDropdown, setShowTimezoneDropdown] = useState(false); const [conferencingOptions, setConferencingOptions] = useState([]); diff --git a/companion/app/(tabs)/(event-types)/index.ios.tsx b/companion/app/(tabs)/(event-types)/index.ios.tsx index 5f2f19e2c8..cfffbcf50d 100644 --- a/companion/app/(tabs)/(event-types)/index.ios.tsx +++ b/companion/app/(tabs)/(event-types)/index.ios.tsx @@ -18,7 +18,7 @@ import { } from "react-native"; import { EmptyScreen } from "@/components/EmptyScreen"; import { EventTypeListItem } from "@/components/event-type-list-item/EventTypeListItem"; -import { LoadingSpinner } from "@/components/LoadingSpinner"; +import { EventTypeListSkeleton } from "@/components/event-type-list-item/EventTypeListItemSkeleton"; import { useCreateEventType, useDeleteEventType, @@ -321,9 +321,22 @@ export default function EventTypesIOS() { if (loading) { return ( - - - + <> + + Event Types + + + + + ); } @@ -476,11 +489,13 @@ export default function EventTypesIOS() { } + refreshControl={} showsVerticalScrollIndicator={false} contentInsetAdjustmentBehavior="automatic" > - {filteredEventTypes.length === 0 && searchQuery.trim() !== "" ? ( + {refreshing ? ( + + ) : filteredEventTypes.length === 0 && searchQuery.trim() !== "" ? ( + {Platform.OS === "web" &&
} - - - + + + ); } @@ -514,11 +519,13 @@ export default function EventTypes() { } + refreshControl={} showsVerticalScrollIndicator={false} contentInsetAdjustmentBehavior="automatic" > - {filteredEventTypes.length === 0 && activeFilterCount > 0 ? ( + {refreshing ? ( + + ) : filteredEventTypes.length === 0 && activeFilterCount > 0 ? ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +interface AvailabilityListSkeletonProps { + count?: number; + iosStyle?: boolean; +} + +export function AvailabilityListSkeleton({ + count = 4, + iosStyle = false, +}: AvailabilityListSkeletonProps) { + if (iosStyle) { + return ( + + + {Array.from({ length: count }).map((_, index) => ( + + ))} + + + ); + } + + return ( + + {Array.from({ length: count }).map((_, index) => ( + + ))} + + ); +} diff --git a/companion/components/booking-list-item/BookingListItemSkeleton.tsx b/companion/components/booking-list-item/BookingListItemSkeleton.tsx new file mode 100644 index 0000000000..a94e16c80d --- /dev/null +++ b/companion/components/booking-list-item/BookingListItemSkeleton.tsx @@ -0,0 +1,103 @@ +import { ScrollView, View } from "react-native"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface BookingListItemSkeletonProps { + isLast?: boolean; +} + +export function BookingListItemSkeleton({ isLast = false }: BookingListItemSkeletonProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +interface BookingListSkeletonProps { + count?: number; + iosStyle?: boolean; +} + +export function BookingListSkeleton({ count = 4, iosStyle = false }: BookingListSkeletonProps) { + if (iosStyle) { + return ( + + + + + {Array.from({ length: count }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton items don't reorder + + ))} + + ); + } + + return ( + + + + + + {Array.from({ length: count }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton items don't reorder + + ))} + + + ); +} diff --git a/companion/components/booking-list-screen/BookingListScreen.tsx b/companion/components/booking-list-screen/BookingListScreen.tsx index b5b686e35e..089c9a6d0e 100644 --- a/companion/components/booking-list-screen/BookingListScreen.tsx +++ b/companion/components/booking-list-screen/BookingListScreen.tsx @@ -14,10 +14,10 @@ import { } from "react-native"; import { FullScreenModal } from "@/components/FullScreenModal"; import { BookingListItem } from "@/components/booking-list-item/BookingListItem"; +import { BookingListSkeleton } from "@/components/booking-list-item/BookingListItemSkeleton"; import { RecurringBookingListItem } from "@/components/booking-list-item/RecurringBookingListItem"; import { BookingModals } from "@/components/booking-modals/BookingModals"; import { EmptyScreen } from "@/components/EmptyScreen"; -import { LoadingSpinner } from "@/components/LoadingSpinner"; import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts"; import { AlertDialog, @@ -636,9 +636,7 @@ export const BookingListScreen: React.FC = ({ {renderHeader?.()} {renderFilterControls?.()} - - - + ); } @@ -717,37 +715,39 @@ export const BookingListScreen: React.FC = ({ {/* Bookings list */} - - item.key} - renderItem={renderListItem} - contentContainerStyle={{ paddingBottom: 90 }} - refreshControl={ - - } - showsVerticalScrollIndicator={false} - contentInsetAdjustmentBehavior="automatic" - style={{ backgroundColor: "white" }} - /> - - - - - + {isManualRefreshing ? ( + + ) : ( + <> + item.key} renderItem={renderListItem} contentContainerStyle={{ paddingBottom: 90 }} - refreshControl={ - - } + refreshControl={} showsVerticalScrollIndicator={false} + contentInsetAdjustmentBehavior="automatic" + style={{ backgroundColor: "white" }} /> - - - + + + + + + item.key} + renderItem={renderListItem} + contentContainerStyle={{ paddingBottom: 90 }} + refreshControl={} + showsVerticalScrollIndicator={false} + /> + + + + + )} {/* Modals */} diff --git a/companion/components/event-type-detail/tabs/AdvancedTab.tsx b/companion/components/event-type-detail/tabs/AdvancedTab.tsx index 6afcbcef30..d02c8be5ee 100644 --- a/companion/components/event-type-detail/tabs/AdvancedTab.tsx +++ b/companion/components/event-type-detail/tabs/AdvancedTab.tsx @@ -6,7 +6,7 @@ */ import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui"; -import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers"; +import { buttonStyle } from "@expo/ui/swift-ui/modifiers"; import { Ionicons } from "@expo/vector-icons"; import { isLiquidGlassAvailable } from "expo-glass-effect"; import { useState } from "react"; diff --git a/companion/components/event-type-detail/tabs/AvailabilityTab.tsx b/companion/components/event-type-detail/tabs/AvailabilityTab.tsx index aada9d65c0..8400715969 100644 --- a/companion/components/event-type-detail/tabs/AvailabilityTab.tsx +++ b/companion/components/event-type-detail/tabs/AvailabilityTab.tsx @@ -6,7 +6,7 @@ */ import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui"; -import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers"; +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"; diff --git a/companion/components/event-type-detail/tabs/BasicsTab.tsx b/companion/components/event-type-detail/tabs/BasicsTab.tsx index 6981549c3a..e8f0db8768 100644 --- a/companion/components/event-type-detail/tabs/BasicsTab.tsx +++ b/companion/components/event-type-detail/tabs/BasicsTab.tsx @@ -5,7 +5,7 @@ */ import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui"; -import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers"; +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"; diff --git a/companion/components/event-type-detail/tabs/LimitsTab.tsx b/companion/components/event-type-detail/tabs/LimitsTab.tsx index 61ecce2beb..cb7ee14243 100644 --- a/companion/components/event-type-detail/tabs/LimitsTab.tsx +++ b/companion/components/event-type-detail/tabs/LimitsTab.tsx @@ -5,13 +5,13 @@ * Uses native iOS ContextMenu for pickers on iOS, Modal-based dropdowns on Android/Web. */ -import { Button, ContextMenu, Host, HStack, Image, Text as SwiftUIText } from "@expo/ui/swift-ui"; -import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers"; +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, - Animated, + type Animated, Platform, Switch, Text, @@ -20,8 +20,6 @@ import { View, } from "react-native"; -import { openInAppBrowser } from "@/utils/browser"; - interface FrequencyLimit { id: number; value: string; diff --git a/companion/components/event-type-detail/tabs/RecurringTab.tsx b/companion/components/event-type-detail/tabs/RecurringTab.tsx index 76d3023e66..3447c9738c 100644 --- a/companion/components/event-type-detail/tabs/RecurringTab.tsx +++ b/companion/components/event-type-detail/tabs/RecurringTab.tsx @@ -5,7 +5,7 @@ */ import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui"; -import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers"; +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"; @@ -222,7 +222,9 @@ export function RecurringTab({ { label: "year", value: "yearly" }, ]} selectedValue={frequencyToLabel[recurringFrequency] || recurringFrequency} - onSelect={(val) => setRecurringFrequency(val as any)} + onSelect={(val) => + setRecurringFrequency(val as "weekly" | "monthly" | "yearly") + } /> ) : ( diff --git a/companion/components/event-type-list-item/EventTypeListItemSkeleton.tsx b/companion/components/event-type-list-item/EventTypeListItemSkeleton.tsx new file mode 100644 index 0000000000..4d794be0cc --- /dev/null +++ b/companion/components/event-type-list-item/EventTypeListItemSkeleton.tsx @@ -0,0 +1,119 @@ +import { View } from "react-native"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface EventTypeListItemSkeletonProps { + isLast?: boolean; +} + +export function EventTypeListItemSkeleton({ isLast = false }: EventTypeListItemSkeletonProps) { + return ( + + + + {/* Title + Link row - matches EventTypeTitle */} + + + + + + {/* Badges row - matches EventTypeBadges */} + + {/* Duration badge skeleton */} + + + + + + {/* Hidden badge skeleton */} + + + + + + {/* Recurrence badge skeleton */} + + + + + + + + {/* Menu button - matches the three-dot menu */} + + + + ); +} + +interface EventTypeListSkeletonProps { + count?: number; +} + +export function EventTypeListSkeleton({ count = 5 }: EventTypeListSkeletonProps) { + return ( + + + {Array.from({ length: count }).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton items don't reorder + + ))} + + + ); +} diff --git a/companion/components/screens/AvailabilityListScreen.tsx b/companion/components/screens/AvailabilityListScreen.tsx index 03fda848c6..2fd424379d 100644 --- a/companion/components/screens/AvailabilityListScreen.tsx +++ b/companion/components/screens/AvailabilityListScreen.tsx @@ -14,10 +14,10 @@ import { } from "react-native"; import { AppPressable } from "@/components/AppPressable"; import { AvailabilityListItem } from "@/components/availability-list-item/AvailabilityListItem"; +import { AvailabilityListSkeleton } from "@/components/availability-list-item/AvailabilityListItemSkeleton"; import { EmptyScreen } from "@/components/EmptyScreen"; import { FullScreenModal } from "@/components/FullScreenModal"; import { Header } from "@/components/Header"; -import { LoadingSpinner } from "@/components/LoadingSpinner"; import { AlertDialog, AlertDialogAction, @@ -39,7 +39,6 @@ import { import { CalComAPIService, type Schedule } from "@/services/calcom"; import { showErrorAlert, showSuccessAlert } from "@/utils/alerts"; import { offlineAwareRefresh } from "@/utils/network"; -import { shadows } from "@/utils/shadows"; export interface AvailabilityListScreenProps { searchQuery: string; @@ -60,18 +59,10 @@ export function AvailabilityListScreen({ const [showActionsModal, setShowActionsModal] = useState(false); const [selectedSchedule, setSelectedSchedule] = useState(null); const [showDeleteModal, setShowDeleteModal] = useState(false); + const [isManualRefreshing, setIsManualRefreshing] = useState(false); // Use React Query hooks - const { - data: schedules = [], - isLoading: loading, - isFetching, - error: queryError, - refetch, - } = useSchedules(); - - // Show refresh indicator when fetching - const refreshing = isFetching && !loading; + const { data: schedules = [], isLoading: loading, error: queryError, refetch } = useSchedules(); const { mutate: createScheduleMutation, isPending: creating } = useCreateSchedule(); const { mutate: deleteScheduleMutation, isPending: deleting } = useDeleteSchedule(); @@ -100,7 +91,12 @@ export function AvailabilityListScreen({ // Data only refreshes on mutations (create/update/delete) or manual pull-to-refresh. // Handle pull-to-refresh (offline-aware) - const onRefresh = () => offlineAwareRefresh(refetch); + const onRefresh = async () => { + setIsManualRefreshing(true); + await offlineAwareRefresh(refetch).finally(() => { + setIsManualRefreshing(false); + }); + }; const handleSearch = (query: string) => { onSearchChange(query); @@ -255,8 +251,8 @@ export function AvailabilityListScreen({ return (
- - + + ); @@ -323,7 +319,7 @@ export function AvailabilityListScreen({ padding: 20, paddingBottom: 90, }} - refreshControl={} + refreshControl={} contentInsetAdjustmentBehavior="automatic" > } + refreshControl={ + + } > - item.id.toString()} - renderItem={({ item, index }) => ( - - )} - refreshControl={} - showsVerticalScrollIndicator={false} - contentInsetAdjustmentBehavior="automatic" - /> + {isManualRefreshing ? ( + + ) : ( + item.id.toString()} + renderItem={({ item, index }) => ( + + )} + refreshControl={} + showsVerticalScrollIndicator={false} + contentInsetAdjustmentBehavior="automatic" + /> + )} diff --git a/companion/components/screens/RescheduleScreen.android.tsx b/companion/components/screens/RescheduleScreen.android.tsx index eb6a2e90a8..6e8fddf006 100644 --- a/companion/components/screens/RescheduleScreen.android.tsx +++ b/companion/components/screens/RescheduleScreen.android.tsx @@ -22,7 +22,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppPressable } from "@/components/AppPressable"; import { useRescheduleBooking } from "@/hooks/useBookings"; import type { Booking } from "@/services/calcom"; -import { showErrorAlert, showSuccessAlert } from "@/utils/alerts"; +import { showErrorAlert } from "@/utils/alerts"; import { safeLogError, safeLogInfo } from "@/utils/safeLogger"; export interface RescheduleScreenProps { diff --git a/companion/components/ui/skeleton.tsx b/companion/components/ui/skeleton.tsx new file mode 100644 index 0000000000..5b896dc929 --- /dev/null +++ b/companion/components/ui/skeleton.tsx @@ -0,0 +1,18 @@ +import { cn } from "@/lib/utils"; +import { View } from "react-native"; + +function Skeleton({ + className, + style, + ...props +}: React.ComponentProps & React.RefAttributes) { + return ( + + ); +} + +export { Skeleton };