diff --git a/companion/app.json b/companion/app.json index 9cb1e75570..6238a8ef1b 100644 --- a/companion/app.json +++ b/companion/app.json @@ -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 diff --git a/companion/app/(tabs)/(availability)/index.tsx b/companion/app/(tabs)/(availability)/index.tsx index 09ebc96bdb..5d064aabbf 100644 --- a/companion/app/(tabs)/(availability)/index.tsx +++ b/companion/app/(tabs)/(availability)/index.tsx @@ -100,14 +100,10 @@ export default function Availability() { > Availability - {/* New Menu */} - + {/* New Button - directly opens create dialog */} + - - - New Availability - - + {/* Profile Button */} {userProfile?.avatarUrl ? ( diff --git a/companion/app/(tabs)/(bookings)/index.ios.tsx b/companion/app/(tabs)/(bookings)/index.ios.tsx index 2953f5dabf..f9bcb4cb4a 100644 --- a/companion/app/(tabs)/(bookings)/index.ios.tsx +++ b/companion/app/(tabs)/(bookings)/index.ios.tsx @@ -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(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(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"; diff --git a/companion/app/(tabs)/(bookings)/index.tsx b/companion/app/(tabs)/(bookings)/index.tsx index 51e20e4d62..27b11ab34e 100644 --- a/companion/app/(tabs)/(bookings)/index.tsx +++ b/companion/app/(tabs)/(bookings)/index.tsx @@ -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(null); const [selectedEventTypeLabel, setSelectedEventTypeLabel] = useState(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(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); diff --git a/companion/app/(tabs)/(more)/index.ios.tsx b/companion/app/(tabs)/(more)/index.ios.tsx index 8a64080af8..205ae8d389 100644 --- a/companion/app/(tabs)/(more)/index.ios.tsx +++ b/companion/app/(tabs)/(more)/index.ios.tsx @@ -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() { ))} + {/* App Settings */} + + + + App Settings + + + setShowLandingPagePicker(true)} + style={{ + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + backgroundColor: colors.backgroundSecondary, + paddingHorizontal: 20, + paddingVertical: 16, + }} + activeOpacity={0.7} + > + + + + First Page + + + + + + {landingPageLabel} + + + + + + + {/* Delete Account Link */} setShowLogoutModal(false)} /> + + {/* Landing Page Picker */} + setShowLandingPagePicker(false)} + /> ); } diff --git a/companion/app/(tabs)/(more)/index.tsx b/companion/app/(tabs)/(more)/index.tsx index 8166e1961e..0479ce894e 100644 --- a/companion/app/(tabs)/(more)/index.tsx +++ b/companion/app/(tabs)/(more)/index.tsx @@ -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() { ))} + {/* App Settings */} + + + + App Settings + + + setShowLandingPagePicker(true)} + className="flex-row items-center justify-between bg-white px-5 py-4 active:bg-[#F8F9FA]" + style={{ backgroundColor: theme.backgroundSecondary }} + > + + + + First Page + + + + + + {landingPageLabel} + + + + + + {/* Delete Account Link */} setShowLogoutModal(false)} /> + + {/* Landing Page Picker Modal */} + setShowLandingPagePicker(false)} + /> ); } diff --git a/companion/app/(tabs)/_layout.tsx b/companion/app/(tabs)/_layout.tsx index d94c024308..dd72a6c896 100644 --- a/companion/app/(tabs)/_layout.tsx +++ b/companion/app/(tabs)/_layout.tsx @@ -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 ; } @@ -38,19 +63,6 @@ export default function TabLayout() { }} disableTransparentOnScrollEdge={true} > - - {Platform.select({ - ios: , - android: ( - } - selectedColor={colors.selected} - /> - ), - })} - Event Types - - {Platform.select({ ios: , @@ -64,6 +76,19 @@ export default function TabLayout() { Bookings + + {Platform.select({ + ios: , + android: ( + } + selectedColor={colors.selected} + /> + ), + })} + Event Types + + {Platform.select({ ios: , diff --git a/companion/app/(tabs)/index.tsx b/companion/app/(tabs)/index.tsx index d986559187..a1ac454952 100644 --- a/companion/app/(tabs)/index.tsx +++ b/companion/app/(tabs)/index.tsx @@ -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 ; + const { preferences, isLoading } = useUserPreferences(); + + if (isLoading) { + return ( + + + + ); + } + + 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 ; } diff --git a/companion/assets/widget-preview.png b/companion/assets/widget-preview.png new file mode 100644 index 0000000000..c5db4c4d9e Binary files /dev/null and b/companion/assets/widget-preview.png differ diff --git a/companion/components/EmptyScreen.tsx b/companion/components/EmptyScreen.tsx index 8610e78315..a218477895 100644 --- a/companion/components/EmptyScreen.tsx +++ b/companion/components/EmptyScreen.tsx @@ -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 ( 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; +} diff --git a/companion/components/LandingPagePicker.tsx b/companion/components/LandingPagePicker.tsx new file mode 100644 index 0000000000..32b2f84878 --- /dev/null +++ b/companion/components/LandingPagePicker.tsx @@ -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 ( + + + + + First Page + + Choose which page opens when you launch the app + + + + + {LANDING_PAGE_OPTIONS.map((option) => { + const isSelected = currentValue === option.value; + return ( + 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} + > + + {option.label} + + {isSelected && } + + ); + })} + + + + + Cancel + + + + + + ); +} diff --git a/companion/components/booking-list-screen/BookingListScreen.tsx b/companion/components/booking-list-screen/BookingListScreen.tsx index 8a6e690c8e..a026433d41 100644 --- a/companion/components/booking-list-screen/BookingListScreen.tsx +++ b/companion/components/booking-list-screen/BookingListScreen.tsx @@ -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 = ({ {renderFilterControls?.()} {/* Empty state - no bookings */} - + {showEmptyState && ( = ({ /> - + )} {/* Search empty state */} - + {showSearchEmptyState && ( = ({ /> - + )} {/* Bookings list */} - - {isManualRefreshing ? ( + {showList && + (isManualRefreshing ? ( + ) : iosStyle ? ( + item.key} + renderItem={renderListItem} + contentContainerStyle={{ paddingBottom: 90 }} + refreshControl={} + showsVerticalScrollIndicator={false} + contentInsetAdjustmentBehavior="automatic" + style={{ backgroundColor: isDark ? "#000000" : "white" }} + /> ) : ( - <> - + + item.key} @@ -734,28 +745,10 @@ export const BookingListScreen: React.FC = ({ contentContainerStyle={{ paddingBottom: 90 }} refreshControl={} showsVerticalScrollIndicator={false} - contentInsetAdjustmentBehavior="automatic" - style={{ backgroundColor: isDark ? "#000000" : "white" }} /> - - - - - - item.key} - renderItem={renderListItem} - contentContainerStyle={{ paddingBottom: 90 }} - refreshControl={} - showsVerticalScrollIndicator={false} - /> - - - - - )} - + + + ))} {/* Modals */} { 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(); diff --git a/companion/hooks/index.ts b/companion/hooks/index.ts index f40491509b..98d341fef9 100644 --- a/companion/hooks/index.ts +++ b/companion/hooks/index.ts @@ -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, diff --git a/companion/hooks/useUserPreferences.ts b/companion/hooks/useUserPreferences.ts new file mode 100644 index 0000000000..9b9ba89ee0 --- /dev/null +++ b/companion/hooks/useUserPreferences.ts @@ -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(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 => { + 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), + }; +}