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:
@@ -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"
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "cal-companion",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user