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:
1 parent
f07bed1a2a
commit
fa53f54815
16 files changed
+560
-80
No files matched your search
+2
-2
@@ -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 ? (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }} />,
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user