feat(companion): add skeleton loading for Event Types, Bookings, and Availability pages (#26687)

* feat(companion): add skeleton loading for Event Types list page

- Install skeleton component from react-native-reusables
- Create EventTypeListItemSkeleton and EventTypeListSkeleton components
- Replace LoadingSpinner with skeleton loading for initial load
- Show skeleton during pull-to-refresh instead of spinner
- Update both index.tsx (Android/Web) and index.ios.tsx (iOS)

* fix(companion): use inline styles for skeleton spacing to ensure distinct elements

* update skeleton

* feat(companion): add skeleton loading for Bookings page

* fix(companion): wrap iOS booking skeleton in ScrollView with contentInsetAdjustmentBehavior

* feat(companion): add skeleton loading for Availability page

* fix(companion): fix iOS availability skeleton layout issue

* fix(companion): remove native spinner from pull-to-refresh on skeleton pages

* fix(companion): remove unused refreshing variable from AvailabilityListScreen

* fix(companion): restore native spinner for empty states during pull-to-refresh

* fix(companion): add try/finally to onRefresh to prevent stuck loading state

* fix - react compiler issue, lint, typecheck
This commit is contained in:
Dhairyashil Shinde
2026-01-11 10:02:07 +05:30
committed by GitHub
parent 539214b164
commit 8b9c3dcbb9
17 changed files with 472 additions and 101 deletions
@@ -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<number | null>(null);
const hasAutoSelectedScheduleRef = useRef(false);
const [initialScheduleId, setInitialScheduleId] = useState<number | null>(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<ConferencingOption[]>([]);
@@ -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 (
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<LoadingSpinner size="large" />
</View>
<>
<Stack.Header
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
blurEffect={isLiquidGlassAvailable() ? undefined : "light"}
>
<Stack.Header.Title large>Event Types</Stack.Header.Title>
</Stack.Header>
<ScrollView
style={{ backgroundColor: "white" }}
contentContainerStyle={{ paddingBottom: 120, paddingTop: 16 }}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
<EventTypeListSkeleton />
</ScrollView>
</>
);
}
@@ -476,11 +489,13 @@ export default function EventTypesIOS() {
<ScrollView
style={{ backgroundColor: "white" }}
contentContainerStyle={{ paddingBottom: 120 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
refreshControl={<RefreshControl refreshing={false} onRefresh={onRefresh} />}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
{filteredEventTypes.length === 0 && searchQuery.trim() !== "" ? (
{refreshing ? (
<EventTypeListSkeleton />
) : filteredEventTypes.length === 0 && searchQuery.trim() !== "" ? (
<View className="flex-1 items-center justify-center bg-gray-50 p-5 pt-20">
<EmptyScreen
icon="search-outline"
+14 -7
View File
@@ -16,9 +16,9 @@ import {
} from "react-native";
import { EmptyScreen } from "@/components/EmptyScreen";
import { EventTypeListItem } from "@/components/event-type-list-item/EventTypeListItem";
import { EventTypeListSkeleton } from "@/components/event-type-list-item/EventTypeListItemSkeleton";
import { FullScreenModal } from "@/components/FullScreenModal";
import { Header } from "@/components/Header";
import { LoadingSpinner } from "@/components/LoadingSpinner";
import {
AlertDialog,
AlertDialogAction,
@@ -375,11 +375,16 @@ export default function EventTypes() {
if (loading) {
return (
<View className="flex-1 bg-gray-100">
<View className="flex-1 bg-white">
{Platform.OS === "web" && <Header />}
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<LoadingSpinner size="large" />
</View>
<ScrollView
style={{ backgroundColor: "white" }}
contentContainerStyle={{ paddingBottom: 90 }}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
<EventTypeListSkeleton />
</ScrollView>
</View>
);
}
@@ -514,11 +519,13 @@ export default function EventTypes() {
<ScrollView
style={{ backgroundColor: "white" }}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
refreshControl={<RefreshControl refreshing={false} onRefresh={onRefresh} />}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
{filteredEventTypes.length === 0 && activeFilterCount > 0 ? (
{refreshing ? (
<EventTypeListSkeleton />
) : filteredEventTypes.length === 0 && activeFilterCount > 0 ? (
<View className="flex-1 items-center justify-center bg-white p-5 pt-20">
<EmptyScreen
icon="filter-outline"
+1
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "cal-companion",
+2 -2
View File
@@ -3,8 +3,8 @@
* Reusable component for displaying and managing multiple event type locations
*/
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers";
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";
@@ -0,0 +1,106 @@
import { ScrollView, View } from "react-native";
import { Skeleton } from "@/components/ui/skeleton";
interface AvailabilityListItemSkeletonProps {
isLast?: boolean;
}
export function AvailabilityListItemSkeleton({
isLast = false,
}: AvailabilityListItemSkeletonProps) {
return (
<View className={`bg-cal-bg ${!isLast ? "border-b border-cal-border" : ""}`}>
<View style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
marginBottom: 4,
}}
>
<Skeleton style={{ height: 18, width: 100, borderRadius: 4 }} />
<Skeleton style={{ height: 20, width: 55, borderRadius: 4, marginLeft: 8 }} />
</View>
<View style={{ marginBottom: 4 }}>
<Skeleton style={{ height: 14, width: "85%", borderRadius: 4, marginBottom: 4 }} />
<Skeleton style={{ height: 14, width: "80%", borderRadius: 4, marginBottom: 4 }} />
<Skeleton style={{ height: 14, width: 80, borderRadius: 4 }} />
</View>
<View style={{ flexDirection: "row", alignItems: "center", marginTop: 8 }}>
<Skeleton style={{ height: 14, width: 14, borderRadius: 7 }} />
<Skeleton style={{ height: 14, width: 90, borderRadius: 4, marginLeft: 6 }} />
</View>
</View>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: 16,
paddingBottom: 16,
}}
>
<Skeleton style={{ height: 32, width: 32, borderRadius: 8 }} />
</View>
</View>
);
}
interface AvailabilityListSkeletonProps {
count?: number;
iosStyle?: boolean;
}
export function AvailabilityListSkeleton({
count = 4,
iosStyle = false,
}: AvailabilityListSkeletonProps) {
if (iosStyle) {
return (
<ScrollView
style={{ backgroundColor: "white" }}
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
>
<View
style={{
flex: 1,
borderRadius: 8,
borderWidth: 1,
borderColor: "#E5E5EA",
marginHorizontal: 8,
marginVertical: 4,
overflow: "hidden",
}}
>
{Array.from({ length: count }).map((_, index) => (
<AvailabilityListItemSkeleton
// biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton items don't reorder
key={`availability-skeleton-${index}`}
isLast={index === count - 1}
/>
))}
</View>
</ScrollView>
);
}
return (
<View
className="flex-1 rounded-lg border border-[#E5E5EA] bg-white"
style={{ paddingHorizontal: 8, paddingVertical: 4 }}
>
{Array.from({ length: count }).map((_, index) => (
<AvailabilityListItemSkeleton
// biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton items don't reorder
key={`availability-skeleton-${index}`}
isLast={index === count - 1}
/>
))}
</View>
);
}
@@ -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 (
<View className={`bg-cal-bg ${!isLast ? "border-b border-cal-border" : ""}`}>
<View style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}>
<View
style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", marginBottom: 8 }}
>
<Skeleton style={{ height: 14, width: 100, borderRadius: 4 }} />
<Skeleton style={{ height: 14, width: 120, borderRadius: 4, marginLeft: 8 }} />
</View>
<View style={{ marginBottom: 12 }}>
<Skeleton style={{ height: 20, width: "90%", borderRadius: 4, marginBottom: 4 }} />
<Skeleton style={{ height: 20, width: "70%", borderRadius: 4 }} />
</View>
<View style={{ marginBottom: 8 }}>
<Skeleton style={{ height: 14, width: "60%", borderRadius: 4 }} />
</View>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Skeleton style={{ height: 16, width: 16, borderRadius: 4 }} />
<Skeleton style={{ height: 14, width: 90, borderRadius: 4, marginLeft: 6 }} />
</View>
</View>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: 16,
paddingBottom: 16,
gap: 8,
}}
>
<Skeleton style={{ height: 32, width: 32, borderRadius: 8 }} />
</View>
</View>
);
}
interface BookingListSkeletonProps {
count?: number;
iosStyle?: boolean;
}
export function BookingListSkeleton({ count = 4, iosStyle = false }: BookingListSkeletonProps) {
if (iosStyle) {
return (
<ScrollView
style={{ backgroundColor: "white" }}
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
>
<View
style={{
backgroundColor: "#f1f1f1",
borderBottomWidth: 1,
borderBottomColor: "#E5E5EA",
paddingHorizontal: 8,
paddingVertical: 12,
}}
>
<Skeleton style={{ height: 16, width: 80, borderRadius: 4 }} />
</View>
{Array.from({ length: count }).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton items don't reorder
<BookingListItemSkeleton key={`booking-skeleton-${index}`} isLast={index === count - 1} />
))}
</ScrollView>
);
}
return (
<View className="flex-1 px-2 pt-4 md:px-4">
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
<View
style={{
backgroundColor: "#f1f1f1",
borderBottomWidth: 1,
borderBottomColor: "#E5E5EA",
paddingHorizontal: 8,
paddingVertical: 12,
}}
>
<Skeleton style={{ height: 16, width: 80, borderRadius: 4 }} />
</View>
{Array.from({ length: count }).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton items don't reorder
<BookingListItemSkeleton key={`booking-skeleton-${index}`} isLast={index === count - 1} />
))}
</View>
</View>
);
}
@@ -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<BookingListScreenProps> = ({
<View className="flex-1 bg-gray-50">
{renderHeader?.()}
{renderFilterControls?.()}
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<LoadingSpinner size="large" />
</View>
<BookingListSkeleton count={4} iosStyle={iosStyle} />
</View>
);
}
@@ -717,37 +715,39 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
{/* Bookings list */}
<Activity mode={showList ? "visible" : "hidden"}>
<Activity mode={iosStyle ? "visible" : "hidden"}>
<FlatList
data={listItems}
keyExtractor={(item) => item.key}
renderItem={renderListItem}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={
<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />
}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
style={{ backgroundColor: "white" }}
/>
</Activity>
<Activity mode={!iosStyle ? "visible" : "hidden"}>
<View className="flex-1 px-2 pt-4 md:px-4">
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
{isManualRefreshing ? (
<BookingListSkeleton count={4} iosStyle={iosStyle} />
) : (
<>
<Activity mode={iosStyle ? "visible" : "hidden"}>
<FlatList
data={listItems}
keyExtractor={(item) => item.key}
renderItem={renderListItem}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={
<RefreshControl refreshing={isManualRefreshing} onRefresh={manualRefresh} />
}
refreshControl={<RefreshControl refreshing={false} onRefresh={manualRefresh} />}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
style={{ backgroundColor: "white" }}
/>
</View>
</View>
</Activity>
</Activity>
<Activity mode={!iosStyle ? "visible" : "hidden"}>
<View className="flex-1 px-2 pt-4 md:px-4">
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
<FlatList
data={listItems}
keyExtractor={(item) => item.key}
renderItem={renderListItem}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={<RefreshControl refreshing={false} onRefresh={manualRefresh} />}
showsVerticalScrollIndicator={false}
/>
</View>
</View>
</Activity>
</>
)}
</Activity>
{/* Modals */}
@@ -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";
@@ -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";
@@ -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";
@@ -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;
@@ -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")
}
/>
</View>
) : (
@@ -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 (
<View className={`bg-cal-bg ${!isLast ? "border-b border-cal-border" : ""}`}>
<View
style={{
paddingHorizontal: 16,
paddingVertical: 16,
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<View style={{ flex: 1, marginRight: 12 }}>
{/* Title + Link row - matches EventTypeTitle */}
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
alignItems: "baseline",
marginBottom: 4,
}}
>
<Skeleton style={{ height: 18, width: 130, borderRadius: 4 }} />
<Skeleton style={{ height: 14, width: 120, borderRadius: 4, marginLeft: 4 }} />
</View>
{/* Badges row - matches EventTypeBadges */}
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
marginTop: 8,
gap: 8,
}}
>
{/* Duration badge skeleton */}
<View
style={{
height: 24,
paddingHorizontal: 8,
flexDirection: "row",
alignItems: "center",
borderRadius: 6,
borderWidth: 1,
borderColor: "#E5E5EA",
backgroundColor: "#F5F5F5",
}}
>
<Skeleton style={{ height: 14, width: 14, borderRadius: 3 }} />
<Skeleton style={{ height: 12, width: 28, borderRadius: 3, marginLeft: 6 }} />
</View>
{/* Hidden badge skeleton */}
<View
style={{
height: 24,
paddingHorizontal: 8,
flexDirection: "row",
alignItems: "center",
borderRadius: 6,
borderWidth: 1,
borderColor: "#E5E5EA",
backgroundColor: "#F5F5F5",
}}
>
<Skeleton style={{ height: 14, width: 14, borderRadius: 3 }} />
<Skeleton style={{ height: 12, width: 40, borderRadius: 3, marginLeft: 6 }} />
</View>
{/* Recurrence badge skeleton */}
<View
style={{
height: 24,
paddingHorizontal: 8,
flexDirection: "row",
alignItems: "center",
borderRadius: 6,
borderWidth: 1,
borderColor: "#E5E5EA",
backgroundColor: "#F5F5F5",
}}
>
<Skeleton style={{ height: 14, width: 14, borderRadius: 3 }} />
<Skeleton style={{ height: 12, width: 44, borderRadius: 3, marginLeft: 6 }} />
</View>
</View>
</View>
{/* Menu button - matches the three-dot menu */}
<Skeleton style={{ height: 36, width: 36, borderRadius: 8 }} />
</View>
</View>
);
}
interface EventTypeListSkeletonProps {
count?: number;
}
export function EventTypeListSkeleton({ count = 5 }: EventTypeListSkeletonProps) {
return (
<View className="px-2 pt-4 md:px-4">
<View className="overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
{Array.from({ length: count }).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton items don't reorder
<EventTypeListItemSkeleton key={`skeleton-${index}`} isLast={index === count - 1} />
))}
</View>
</View>
);
}
@@ -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<Schedule | null>(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 (
<View className="flex-1 bg-[#f8f9fa]">
<Header />
<View className="flex-1 items-center justify-center p-5">
<LoadingSpinner size="large" />
<View className="flex-1 px-2 pt-4 md:px-4">
<AvailabilityListSkeleton iosStyle={Platform.OS === "ios"} />
</View>
</View>
);
@@ -323,7 +319,7 @@ export function AvailabilityListScreen({
padding: 20,
paddingBottom: 90,
}}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
refreshControl={<RefreshControl refreshing={isManualRefreshing} onRefresh={onRefresh} />}
contentInsetAdjustmentBehavior="automatic"
>
<EmptyScreen
@@ -349,7 +345,9 @@ export function AvailabilityListScreen({
padding: 20,
paddingBottom: 90,
}}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
refreshControl={
<RefreshControl refreshing={isManualRefreshing} onRefresh={onRefresh} />
}
>
<EmptyScreen
icon="search-outline"
@@ -361,29 +359,33 @@ export function AvailabilityListScreen({
{/* Schedules list */}
<Activity mode={showList ? "visible" : "hidden"}>
<FlatList
className="flex-1 rounded-lg border border-[#E5E5EA] bg-white"
contentContainerStyle={{
paddingBottom: 90,
paddingHorizontal: 8,
paddingVertical: 4,
}}
data={filteredSchedules}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item, index }) => (
<AvailabilityListItem
item={item}
index={index}
handleSchedulePress={handleSchedulePress}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
onSetAsDefault={handleSetAsDefault}
/>
)}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
/>
{isManualRefreshing ? (
<AvailabilityListSkeleton iosStyle={Platform.OS === "ios"} />
) : (
<FlatList
className="flex-1 rounded-lg border border-[#E5E5EA] bg-white"
contentContainerStyle={{
paddingBottom: 90,
paddingHorizontal: 8,
paddingVertical: 4,
}}
data={filteredSchedules}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item, index }) => (
<AvailabilityListItem
item={item}
index={index}
handleSchedulePress={handleSchedulePress}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
onSetAsDefault={handleSetAsDefault}
/>
)}
refreshControl={<RefreshControl refreshing={false} onRefresh={onRefresh} />}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
/>
)}
</Activity>
</Activity>
@@ -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 {
+18
View File
@@ -0,0 +1,18 @@
import { cn } from "@/lib/utils";
import { View } from "react-native";
function Skeleton({
className,
style,
...props
}: React.ComponentProps<typeof View> & React.RefAttributes<View>) {
return (
<View
className={cn("rounded-md", className)}
style={[{ backgroundColor: "#E5E5EA" }, style]}
{...props}
/>
);
}
export { Skeleton };