feat(companion): add configurable landing page (#27267)

* feat(companion): add configurable landing page feature

- Add useUserPreferences hook for persistent storage of landing page preference
- Add LandingPagePicker component for both iOS and Android/web platforms
- Update tabs index to redirect based on user preference
- Update bookings index to accept initial filter from URL params
- Add App Settings section in More screen with landing page selector
- Clear user preferences on logout for fresh state

* fix(companion): remove try-finally for React Compiler compatibility

* fix(companion): use router.replace for dynamic landing page redirect

Replace Redirect component with router.replace() to fix TypeScript
strict typing issue with expo-router's Href type for dynamic routes.

* fix(companion): use literal route strings for TypeScript strict typing

Use switch statement with literal route strings instead of dynamic
string variable to satisfy expo-router's strict Href type checking.

* working fix

* better css
This commit is contained in:
Dhairyashil Shinde authored and GitHub committed 2026-02-13 11:28:42 -03:00
1 parent f07bed1a2a
commit fa53f54815
16 files changed
+560 -80

No files matched your search

+2 -2
View File
@@ -3,7 +3,7 @@
"name": "Calcom",
"slug": "calcom-companion",
"scheme": "calcom",
"version": "1.0.3",
"version": "1.0.4",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -102,7 +102,7 @@
"minWidth": "180dp",
"minHeight": "110dp",
"description": "View your upcoming Cal.com bookings",
"previewImage": "./assets/icon.png",
"previewImage": "./assets/widget-preview.png",
"resizeMode": "horizontal|vertical",
"targetCellWidth": 3,
"targetCellHeight": 2
@@ -100,14 +100,10 @@ export default function Availability() {
>
<Stack.Header.Title large>Availability</Stack.Header.Title>
<Stack.Header.Right>
{/* New Menu */}
<Stack.Header.Menu>
{/* New Button - directly opens create dialog */}
<Stack.Header.Button onPress={handleCreateNew}>
<Stack.Header.Icon sf="plus" />
<Stack.Header.MenuAction icon="clock" onPress={handleCreateNew}>
New Availability
</Stack.Header.MenuAction>
</Stack.Header.Menu>
</Stack.Header.Button>
{/* Profile Button */}
{userProfile?.avatarUrl ? (
+34 -4
View File
@@ -1,21 +1,36 @@
import type { NativeStackHeaderItemMenuAction } from "@react-navigation/native-stack";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack } from "expo-router";
import { useState } from "react";
import { Stack, useLocalSearchParams } from "expo-router";
import { useState, useEffect, useRef } from "react";
import { useColorScheme } from "react-native";
import { BookingListScreen } from "@/components/booking-list-screen/BookingListScreen";
import { useEventTypes } from "@/hooks";
import { useActiveBookingFilter } from "@/hooks/useActiveBookingFilter";
import { type BookingFilter, useActiveBookingFilter } from "@/hooks/useActiveBookingFilter";
const VALID_FILTERS: BookingFilter[] = [
"upcoming",
"unconfirmed",
"recurring",
"past",
"cancelled",
];
function isValidBookingFilter(value: string | undefined): value is BookingFilter {
return value !== undefined && VALID_FILTERS.includes(value as BookingFilter);
}
export default function Bookings() {
const { filter } = useLocalSearchParams<{ filter?: string }>();
const initialFilter = isValidBookingFilter(filter) ? filter : "upcoming";
const [searchQuery, setSearchQuery] = useState("");
const [selectedEventTypeId, setSelectedEventTypeId] = useState<number | null>(null);
const { data: eventTypes } = useEventTypes();
// Use the active booking filter hook
const { activeFilter, filterOptions, filterParams, handleFilterChange } = useActiveBookingFilter(
"upcoming",
initialFilter,
() => {
// Clear dependent filters when status filter changes
setSearchQuery("");
@@ -23,6 +38,21 @@ export default function Bookings() {
}
);
// Track if we want to ignore the next activeFilter change (because it came from URL sync)
const lastUrlFilter = useRef<string | null>(null);
// Update filter when URL params change (for when component is already mounted)
useEffect(() => {
if (
isValidBookingFilter(filter) &&
filter !== activeFilter &&
filter !== lastUrlFilter.current
) {
lastUrlFilter.current = filter;
handleFilterChange(filter);
}
}, [filter, activeFilter, handleFilterChange]);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const textColor = isDark ? "#FFFFFF" : "#000000";
+48 -10
View File
@@ -1,5 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import { useLocalSearchParams } from "expo-router";
import { useState, useEffect, useRef } from "react";
import { Text, TextInput, useColorScheme, View } from "react-native";
import { BookingListScreen } from "@/components/booking-list-screen/BookingListScreen";
import { Header } from "@/components/Header";
@@ -10,11 +11,26 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AppPressable } from "@/components/AppPressable";
import { useActiveBookingFilter } from "@/hooks/useActiveBookingFilter";
import { type BookingFilter, useActiveBookingFilter } from "@/hooks/useActiveBookingFilter";
import { useEventTypes } from "@/hooks";
import { getColors } from "@/constants/colors";
const VALID_FILTERS: BookingFilter[] = [
"upcoming",
"unconfirmed",
"recurring",
"past",
"cancelled",
];
function isValidBookingFilter(value: string | undefined): value is BookingFilter {
return value !== undefined && VALID_FILTERS.includes(value as BookingFilter);
}
export default function Bookings() {
const { filter } = useLocalSearchParams<{ filter?: string }>();
const initialFilter = isValidBookingFilter(filter) ? filter : "upcoming";
const [searchQuery, setSearchQuery] = useState("");
const [selectedEventTypeId, setSelectedEventTypeId] = useState<number | null>(null);
const [selectedEventTypeLabel, setSelectedEventTypeLabel] = useState<string | null>(null);
@@ -26,15 +42,37 @@ export default function Bookings() {
const { data: eventTypes = [], isLoading: eventTypesLoading } = useEventTypes();
// Use the active booking filter hook
const { activeFilter, filterOptions, filterParams, handleFilterChange } = useActiveBookingFilter(
"upcoming",
() => {
// Clear dependent filters when status filter changes
setSearchQuery("");
setSelectedEventTypeId(null);
setSelectedEventTypeLabel(null);
const {
activeFilter,
filterOptions,
filterParams,
handleFilterChange: originalHandleFilterChange,
} = useActiveBookingFilter(initialFilter, () => {
// Clear dependent filters when status filter changes
setSearchQuery("");
setSelectedEventTypeId(null);
setSelectedEventTypeLabel(null);
});
// Wrap handleFilterChange with debug logging
const handleFilterChange = (filter: string) => {
originalHandleFilterChange(filter as BookingFilter);
};
// Track if we want to ignore the next activeFilter change (because it came from URL sync)
const lastUrlFilter = useRef<string | null>(null);
// Reactively update filter when URL params change (e.g., from deep link)
useEffect(() => {
if (
isValidBookingFilter(filter) &&
filter !== activeFilter &&
filter !== lastUrlFilter.current
) {
lastUrlFilter.current = filter;
originalHandleFilterChange(filter);
}
);
}, [filter, activeFilter, originalHandleFilterChange]);
const handleSearch = (query: string) => {
setSearchQuery(query);
+90 -1
View File
@@ -12,11 +12,13 @@ import {
useColorScheme,
View,
} from "react-native";
import { LandingPagePicker } from "@/components/LandingPagePicker";
import { LogoutConfirmModal } from "@/components/LogoutConfirmModal";
import { useAuth } from "@/contexts/AuthContext";
import { useQueryContext } from "@/contexts/QueryContext";
import { useUserProfile } from "@/hooks";
import { showErrorAlert, showNotAvailableAlert } from "@/utils/alerts";
import { type LandingPage, useUserPreferences } from "@/hooks/useUserPreferences";
import { showErrorAlert, showSuccessAlert, showNotAvailableAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { getColors } from "@/constants/colors";
@@ -33,7 +35,9 @@ export default function More() {
const router = useRouter();
const { logout } = useAuth();
const { clearCache } = useQueryContext();
const { preferences, setLandingPage, landingPageLabel } = useUserPreferences();
const [showLogoutModal, setShowLogoutModal] = useState(false);
const [showLandingPagePicker, setShowLandingPagePicker] = useState(false);
const { data: userProfile } = useUserProfile();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
@@ -48,6 +52,15 @@ export default function More() {
border: isDark ? "#4D4D4D" : "#E5E5EA",
};
const handleLandingPageSelect = async (value: LandingPage) => {
try {
await setLandingPage(value);
showSuccessAlert("Saved", "First page updated");
} catch {
showErrorAlert("Error", "Failed to save preference. Please try again.");
}
};
const performLogout = async () => {
try {
// Clear in-memory cache before logout
@@ -183,6 +196,74 @@ export default function More() {
))}
</View>
{/* App Settings */}
<View
style={{
marginTop: 24,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.backgroundSecondary,
overflow: "hidden",
}}
>
<View
style={{
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: isDark ? "#2C2C2E" : "#F9FAFB",
paddingHorizontal: 16,
paddingVertical: 8,
}}
>
<Text
style={{ color: isDark ? "#A3A3A3" : "#6B7280" }}
className="text-xs font-semibold uppercase"
>
App Settings
</Text>
</View>
<TouchableOpacity
onPress={() => setShowLandingPagePicker(true)}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: colors.backgroundSecondary,
paddingHorizontal: 20,
paddingVertical: 16,
}}
activeOpacity={0.7}
>
<View className="flex-row items-center">
<Ionicons name="home-outline" size={20} color={colors.text} />
<Text style={{ color: colors.text }} className="ml-3 text-base font-semibold">
First Page
</Text>
</View>
<View className="flex-row items-center">
<View
style={{
marginLeft: 12,
marginRight: 8,
borderRadius: 6,
backgroundColor: isDark ? "#3A3A3C" : "#F3F4F6",
paddingHorizontal: 10,
paddingVertical: 4,
}}
>
<Text
style={{ color: isDark ? "#A3A3A3" : "#4B5563" }}
className="text-sm font-medium"
>
{landingPageLabel}
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
</View>
{/* Delete Account Link */}
<View
style={{
@@ -268,6 +349,14 @@ export default function More() {
}}
onCancel={() => setShowLogoutModal(false)}
/>
{/* Landing Page Picker */}
<LandingPagePicker
visible={showLandingPagePicker}
currentValue={preferences.landingPage}
onSelect={handleLandingPageSelect}
onClose={() => setShowLandingPagePicker(false)}
/>
</>
);
}
+65 -1
View File
@@ -10,10 +10,12 @@ import {
View,
} from "react-native";
import { Header } from "@/components/Header";
import { LandingPagePicker } from "@/components/LandingPagePicker";
import { LogoutConfirmModal } from "@/components/LogoutConfirmModal";
import { useAuth } from "@/contexts/AuthContext";
import { useQueryContext } from "@/contexts/QueryContext";
import { showErrorAlert } from "@/utils/alerts";
import { type LandingPage, useUserPreferences } from "@/hooks/useUserPreferences";
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { getColors } from "@/constants/colors";
@@ -28,11 +30,22 @@ interface MoreMenuItem {
export default function More() {
const { logout } = useAuth();
const { clearCache } = useQueryContext();
const { preferences, setLandingPage, landingPageLabel } = useUserPreferences();
const [showLogoutModal, setShowLogoutModal] = useState(false);
const [showLandingPagePicker, setShowLandingPagePicker] = useState(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const handleLandingPageSelect = async (value: LandingPage) => {
try {
await setLandingPage(value);
showSuccessAlert("Saved", "First page updated");
} catch {
showErrorAlert("Error", "Failed to save preference. Please try again.");
}
};
const performLogout = async () => {
try {
// Clear in-memory cache before logout
@@ -136,6 +149,49 @@ export default function More() {
))}
</View>
{/* App Settings */}
<View
className="mt-6 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white"
style={{ borderColor: theme.border, backgroundColor: theme.backgroundSecondary }}
>
<View
className="border-b border-[#E5E5EA] bg-gray-50 px-4 py-2"
style={{ borderBottomColor: theme.border, backgroundColor: isDark ? "#2C2C2E" : undefined }}
>
<Text
className="text-xs font-semibold uppercase text-gray-500"
style={{ color: theme.textSecondary }}
>
App Settings
</Text>
</View>
<TouchableOpacity
onPress={() => setShowLandingPagePicker(true)}
className="flex-row items-center justify-between bg-white px-5 py-4 active:bg-[#F8F9FA]"
style={{ backgroundColor: theme.backgroundSecondary }}
>
<View className="flex-row items-center">
<Ionicons name="home-outline" size={20} color={theme.text} />
<Text className="ml-3 text-base font-semibold" style={{ color: theme.text }}>
First Page
</Text>
</View>
<View className="flex-row items-center">
<View
className="ml-3 rounded-md bg-gray-100 px-2.5 py-1"
style={{ backgroundColor: isDark ? "#3A3A3C" : undefined }}
>
<Text
className="text-sm font-medium text-gray-600"
style={{ color: theme.textSecondary }}
>
{landingPageLabel}
</Text>
</View>
</View>
</TouchableOpacity>
</View>
{/* Delete Account Link */}
<View
className="mt-6 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white"
@@ -200,6 +256,14 @@ export default function More() {
}}
onCancel={() => setShowLogoutModal(false)}
/>
{/* Landing Page Picker Modal */}
<LandingPagePicker
visible={showLandingPagePicker}
currentValue={preferences.landingPage}
onSelect={handleLandingPageSelect}
onClose={() => setShowLandingPagePicker(false)}
/>
</View>
);
}
+39 -14
View File
@@ -1,9 +1,11 @@
import { Ionicons } from "@expo/vector-icons";
import type { ColorValue, ImageSourcePropType } from "react-native";
import { Tabs, VectorIcon } from "expo-router";
import { Tabs, VectorIcon, useRouter } from "expo-router";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useColorScheme } from "react-native";
import { Platform } from "react-native";
import { useEffect, useRef } from "react";
import { getRouteFromPreference, useUserPreferences } from "@/hooks/useUserPreferences";
// Type for vector icon families that support getImageSource
type VectorIconFamily = {
@@ -11,6 +13,9 @@ type VectorIconFamily = {
};
export default function TabLayout() {
const router = useRouter();
const { preferences, isLoading } = useUserPreferences();
const hasNavigated = useRef(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
@@ -22,6 +27,26 @@ export default function TabLayout() {
indicator: isDark ? "#FFFFFF15" : "#00000015",
};
// Navigate to preferred landing page on first load
// Since Bookings is the default first tab, we only navigate if preference is different
useEffect(() => {
if (!isLoading && !hasNavigated.current) {
hasNavigated.current = true;
// Only navigate if preference is not the default (bookings)
if (preferences.landingPage !== "bookings") {
const route = getRouteFromPreference(preferences.landingPage);
console.log("[TabLayout] Navigating to preferred landing page:", route);
// Use a minimal delay to ensure NativeTabs is initialized
setTimeout(() => {
// biome-ignore lint/suspicious/noExplicitAny: expo-router types don't support dynamic route strings from preferences
router.navigate(route as any);
}, 50);
}
}
}, [isLoading, preferences.landingPage, router]);
if (Platform.OS === "web") {
return <WebTabs colors={colors} />;
}
@@ -38,19 +63,6 @@ export default function TabLayout() {
}}
disableTransparentOnScrollEdge={true}
>
<NativeTabs.Trigger name="(event-types)">
{Platform.select({
ios: <NativeTabs.Trigger.Icon sf="link" />,
android: (
<NativeTabs.Trigger.Icon
src={<VectorIcon family={Ionicons as VectorIconFamily} name="link-outline" />}
selectedColor={colors.selected}
/>
),
})}
<NativeTabs.Trigger.Label>Event Types</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(bookings)">
{Platform.select({
ios: <NativeTabs.Trigger.Icon sf="calendar" />,
@@ -64,6 +76,19 @@ export default function TabLayout() {
<NativeTabs.Trigger.Label>Bookings</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(event-types)">
{Platform.select({
ios: <NativeTabs.Trigger.Icon sf="link" />,
android: (
<NativeTabs.Trigger.Icon
src={<VectorIcon family={Ionicons as VectorIconFamily} name="link-outline" />}
selectedColor={colors.selected}
/>
),
})}
<NativeTabs.Trigger.Label>Event Types</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(availability)">
{Platform.select({
ios: <NativeTabs.Trigger.Icon sf={{ default: "clock", selected: "clock.fill" }} />,
+17 -1
View File
@@ -1,5 +1,21 @@
import { Redirect } from "expo-router";
import { ActivityIndicator, View } from "react-native";
import { getRouteFromPreference, useUserPreferences } from "@/hooks/useUserPreferences";
export default function TabsIndex() {
return <Redirect href="/(tabs)/(event-types)" />;
const { preferences, isLoading } = useUserPreferences();
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-white">
<ActivityIndicator size="small" color="#111827" />
</View>
);
}
const route = getRouteFromPreference(preferences.landingPage);
console.log("[TabsIndex] Redirecting to:", route, "from preference:", preferences.landingPage);
// biome-ignore lint/suspicious/noExplicitAny: expo-router types don't support dynamic route strings from preferences
return <Redirect href={route as any} />;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

+2 -9
View File
@@ -26,9 +26,7 @@ export function EmptyScreen({
const colors = {
text: isDark ? "#FFFFFF" : "#111827",
textSecondary: isDark ? "#A3A3A3" : "#6B7280",
background: isDark ? "#171717" : "#FFFFFF",
backgroundSecondary: isDark ? "#2C2C2E" : "#F3F4F6",
border: isDark ? "#4D4D4D" : "#D1D5DB",
iconBackground: isDark ? "#2C2C2E" : "#F3F4F6",
icon: isDark ? "#E5E5EA" : "#374151",
buttonBg: isDark ? "#FFFFFF" : "#111827",
buttonText: isDark ? "#000000" : "#FFFFFF",
@@ -37,9 +35,6 @@ export function EmptyScreen({
return (
<View
style={{
backgroundColor: colors.background,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 8,
padding: 28,
width: "100%",
@@ -54,9 +49,7 @@ export function EmptyScreen({
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: colors.backgroundSecondary,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.iconBackground,
alignItems: "center",
justifyContent: "center",
}}
@@ -0,0 +1,53 @@
import { useEffect, useRef } from "react";
import { ActionSheetIOS } from "react-native";
import { LANDING_PAGE_OPTIONS, type LandingPage } from "@/hooks/useUserPreferences";
interface LandingPagePickerProps {
visible: boolean;
currentValue: LandingPage;
onSelect: (value: LandingPage) => void;
onClose: () => void;
}
export function LandingPagePicker({
visible,
currentValue: _currentValue,
onSelect,
onClose,
}: LandingPagePickerProps) {
const sheetShownRef = useRef(false);
useEffect(() => {
if (visible && !sheetShownRef.current) {
sheetShownRef.current = true;
const options = [...LANDING_PAGE_OPTIONS.map((opt) => opt.label), "Cancel"];
const cancelButtonIndex = options.length - 1;
ActionSheetIOS.showActionSheetWithOptions(
{
title: "First Page",
message: "Choose which page opens when you launch the app",
options,
cancelButtonIndex,
destructiveButtonIndex: undefined,
},
(buttonIndex) => {
if (buttonIndex !== cancelButtonIndex && buttonIndex < LANDING_PAGE_OPTIONS.length) {
const selectedOption = LANDING_PAGE_OPTIONS[buttonIndex];
if (selectedOption) {
onSelect(selectedOption.value);
}
}
onClose();
}
);
}
if (!visible) {
sheetShownRef.current = false;
}
}, [visible, onSelect, onClose]);
return null;
}
@@ -0,0 +1,75 @@
import { Ionicons } from "@expo/vector-icons";
import { Platform, Text, TouchableOpacity, View } from "react-native";
import { FullScreenModal } from "./FullScreenModal";
import {
LANDING_PAGE_OPTIONS,
type LandingPage,
type LandingPageOption,
} from "@/hooks/useUserPreferences";
interface LandingPagePickerProps {
visible: boolean;
currentValue: LandingPage;
onSelect: (value: LandingPage) => void;
onClose: () => void;
}
export function LandingPagePicker({
visible,
currentValue,
onSelect,
onClose,
}: LandingPagePickerProps) {
const handleSelect = (option: LandingPageOption) => {
onSelect(option.value);
onClose();
};
return (
<FullScreenModal visible={visible} onRequestClose={onClose}>
<View className="flex-1 items-center justify-center bg-black/50">
<View className="mx-4 min-w-[320px] max-w-[400px] rounded-lg bg-white shadow-lg">
<View className="border-b border-gray-200 px-4 py-3">
<Text className="text-lg font-semibold text-gray-900">First Page</Text>
<Text className="mt-1 text-sm text-gray-500">
Choose which page opens when you launch the app
</Text>
</View>
<View className="py-2">
{LANDING_PAGE_OPTIONS.map((option) => {
const isSelected = currentValue === option.value;
return (
<TouchableOpacity
key={option.value}
onPress={() => handleSelect(option)}
className="flex-row items-center justify-between px-4 py-3 active:bg-gray-100"
style={Platform.OS === "web" ? { cursor: "pointer" } : undefined}
activeOpacity={0.7}
>
<Text
className={`text-base ${isSelected ? "font-semibold text-gray-900" : "text-gray-700"}`}
>
{option.label}
</Text>
{isSelected && <Ionicons name="checkmark" size={20} color="#111827" />}
</TouchableOpacity>
);
})}
</View>
<View className="border-t border-gray-200 px-4 py-3">
<TouchableOpacity
onPress={onClose}
className="items-center rounded-md border border-gray-300 bg-white px-4 py-2"
style={Platform.OS === "web" ? { cursor: "pointer" } : undefined}
activeOpacity={0.7}
>
<Text className="font-medium text-gray-700">Cancel</Text>
</TouchableOpacity>
</View>
</View>
</View>
</FullScreenModal>
);
}
@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { Activity, useMemo, useState } from "react";
import React, { useMemo, useState } from "react";
import {
Alert,
FlatList,
@@ -677,7 +677,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
{renderFilterControls?.()}
{/* Empty state - no bookings */}
<Activity mode={showEmptyState ? "visible" : "hidden"}>
{showEmptyState && (
<View className="flex-1 bg-gray-50 dark:bg-black">
<ScrollView
contentContainerStyle={{
@@ -696,10 +696,10 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
/>
</ScrollView>
</View>
</Activity>
)}
{/* Search empty state */}
<Activity mode={showSearchEmptyState ? "visible" : "hidden"}>
{showSearchEmptyState && (
<View className="flex-1 bg-gray-50 dark:bg-black">
<ScrollView
contentContainerStyle={{
@@ -718,15 +718,26 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
/>
</ScrollView>
</View>
</Activity>
)}
{/* Bookings list */}
<Activity mode={showList ? "visible" : "hidden"}>
{isManualRefreshing ? (
{showList &&
(isManualRefreshing ? (
<BookingListSkeleton count={4} iosStyle={iosStyle} />
) : iosStyle ? (
<FlatList
data={listItems}
keyExtractor={(item) => item.key}
renderItem={renderListItem}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={<RefreshControl refreshing={false} onRefresh={manualRefresh} />}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
style={{ backgroundColor: isDark ? "#000000" : "white" }}
/>
) : (
<>
<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 dark:border-[#4D4D4D] dark:bg-black">
<FlatList
data={listItems}
keyExtractor={(item) => item.key}
@@ -734,28 +745,10 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={<RefreshControl refreshing={false} onRefresh={manualRefresh} />}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
style={{ backgroundColor: isDark ? "#000000" : "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 dark:border-[#4D4D4D] dark:bg-black">
<FlatList
data={listItems}
keyExtractor={(item) => item.key}
renderItem={renderListItem}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={<RefreshControl refreshing={false} onRefresh={manualRefresh} />}
showsVerticalScrollIndicator={false}
/>
</View>
</View>
</Activity>
</>
)}
</Activity>
</View>
</View>
))}
{/* Modals */}
<BookingModals
+8 -1
View File
@@ -1,4 +1,5 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
import { USER_PREFERENCES_KEY } from "@/hooks/useUserPreferences";
import { CalComAPIService } from "@/services/calcom";
import {
type CalComOAuthService,
@@ -8,7 +9,7 @@ import {
import type { UserProfile } from "@/services/types/users.types";
import { WebAuthService } from "@/services/webAuth";
import { clearQueryCache } from "@/utils/queryPersister";
import { secureStorage } from "@/utils/storage";
import { generalStorage, secureStorage } from "@/utils/storage";
/**
* Simplified user info stored in auth context
@@ -144,6 +145,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
const logout = useCallback(async () => {
try {
await clearAuth();
// Clear user preferences to ensure fresh state for next user
try {
await generalStorage.removeItem(USER_PREFERENCES_KEY);
} catch (prefsError) {
console.warn("Failed to clear user preferences during logout:", prefsError);
}
// Clear all cached queries to ensure fresh data on re-login
try {
await clearQueryCache();
+9
View File
@@ -65,6 +65,15 @@ export {
} from "./useSchedules";
// Toast hook
export { type ToastState, type ToastType, useToast } from "./useToast";
// User Preferences hooks
export {
getRouteFromPreference,
LANDING_PAGE_OPTIONS,
type LandingPage,
type LandingPageOption,
type UserPreferences,
useUserPreferences,
} from "./useUserPreferences";
// User Profile hooks
export {
type UpdateUserProfileInput,
+92
View File
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useState } from "react";
import { generalStorage } from "@/utils/storage";
export type LandingPage = "event-types" | "bookings" | "bookings:unconfirmed";
export interface UserPreferences {
landingPage: LandingPage;
}
export const USER_PREFERENCES_KEY = "cal_user_preferences";
const DEFAULT_PREFERENCES: UserPreferences = {
landingPage: "event-types",
};
export interface LandingPageOption {
value: LandingPage;
label: string;
}
export const LANDING_PAGE_OPTIONS: LandingPageOption[] = [
{ value: "event-types", label: "Event Types" },
{ value: "bookings", label: "Bookings" },
{ value: "bookings:unconfirmed", label: "Bookings (Unconfirmed)" },
];
export function getLandingPageLabel(landingPage: LandingPage): string {
const option = LANDING_PAGE_OPTIONS.find((opt) => opt.value === landingPage);
return option?.label ?? "Event Types";
}
export function getRouteFromPreference(landingPage: LandingPage): string {
switch (landingPage) {
case "event-types":
return "/(tabs)/(event-types)";
case "bookings":
return "/(tabs)/(bookings)";
case "bookings:unconfirmed":
return "/(tabs)/(bookings)?filter=unconfirmed";
default:
return "/(tabs)/(event-types)";
}
}
export function useUserPreferences() {
const [preferences, setPreferences] = useState<UserPreferences>(DEFAULT_PREFERENCES);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadPreferences = async () => {
try {
const stored = await generalStorage.getItem(USER_PREFERENCES_KEY);
if (stored) {
const parsed = JSON.parse(stored) as UserPreferences;
setPreferences(parsed);
}
setIsLoading(false);
} catch {
console.warn("Failed to load user preferences");
setIsLoading(false);
}
};
loadPreferences();
}, []);
const setLandingPage = useCallback(
async (landingPage: LandingPage): Promise<void> => {
const newPreferences: UserPreferences = {
...preferences,
landingPage,
};
setPreferences(newPreferences);
try {
await generalStorage.setItem(USER_PREFERENCES_KEY, JSON.stringify(newPreferences));
} catch (error) {
console.error("Failed to save user preferences:", error);
throw error;
}
},
[preferences]
);
return {
preferences,
isLoading,
setLandingPage,
landingPageLabel: getLandingPageLabel(preferences.landingPage),
};
}