Files
cal.diy/companion/components/Header.tsx
T
0e6bf54fa2 feat(companion): dark mode (#27305)
* feat(companion): add dark mode support using NativeWind system preference

- Set userInterfaceStyle to 'automatic' in app.json to follow system preference
- Add useColorScheme hook from NativeWind to detect color scheme
- Update root layout with dynamic colors for StatusBar, containers, and modals
- Update tabs layout with dynamic colors for tab bar and icons
- Update all Stack.Screen options to use dynamic background colors and blur effects
- Support both iOS and Android with appropriate dark mode colors

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix(companion): use NativeWind dark: variant classes for automatic dark mode

Use dark: Tailwind variant classes instead of conditional logic based on
useColorScheme for the container background. This ensures NativeWind
automatically applies dark mode styles based on system preference.

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix(companion): use darkMode 'media' for automatic dark mode detection

- Changed darkMode from 'class' to 'media' in tailwind.config.js
- Updated global.css to use @media (prefers-color-scheme: dark) instead of .dark selector
- This ensures NativeWind properly detects system color scheme on iOS/Android

Co-Authored-By: peer@cal.com <peer@cal.com>

* dark mode starting

* feat(companion): add dark mode support to core infrastructure (Phase 1)

* feat(companion): add dark mode support to tab pages (Phase 2)

* fix(companion): add dark mode support to booking list components for font visibility

* fix(companion): add dark mode support to EventTypeListItem components

* event types and bookings page

* Availability list page

* more page, and some ui fixes

* feat(companion): add dark mode support to detail screens and modals

- BookingDetailScreen (iOS & Android): dynamic colors for all UI elements
- AvailabilityDetailScreen (iOS & Android): dark mode backgrounds and text
- profile-sheet (iOS & Android): dark mode support for profile modal
- RescheduleScreen (all platforms): dark mode for date/time pickers
- EditLocationScreen (iOS & Android): dark mode for location editor
- AddGuestsScreen: partial dark mode implementation (in progress)

Uses iOS system colors: #000000 (black), #1C1C1E (secondary), #FFFFFF (white), #38383A (border), #8E8E93 (text secondary)

* fix typecheck and lint

* feat(companion): add dark mode support to remaining screens

- ScreenWrapper: Fix hardcoded #800020 color with dynamic destructive color
- EditAvailabilityDayScreen: Add dark mode for Android/Web and iOS
- EditAvailabilityHoursScreen: Add dark mode for Android/Web and iOS
- EditAvailabilityNameScreen: Add dark mode for Android/Web and iOS
- EditAvailabilityOverrideScreen: Add dark mode for Web, iOS, and Android
- MarkNoShowScreen: Add dark mode with dynamic colors for no-show states
- MeetingSessionDetailsScreen: Add dark mode for session details
- ViewRecordingsScreen: Add dark mode for recordings list

Uses iOS system colors (#000000, #1C1C1E, #38383A, #8E8E93) and
useColorScheme hook for automatic theme detection.

* fix(companion): fix iOS modal dark mode backgrounds for transparent glass UI

* fix(companion): fix dark mode for booking action screens in transparent background mode

* fix(companion): fix Android dark mode for transparent background in EditAvailabilityOverrideScreen

* refactor(companion): centralize dark mode colors and align with main website

- Update tailwind.config.js with centralized color definitions
- Create constants/colors.ts for inline style color references
- Replace iOS system colors with main website neutral grays:
  - #1C1C1E -> #171717 (secondary dark background)
  - #38383A -> #4D4D4D (dark border)
  - #8E8E93 -> #A3A3A3 (secondary text dark)
- Keep pure black (#000000) for main backgrounds (OLED friendly)
- Add semantic color mappings for consistent UI styling

* feat(companion): complete dark mode implementation across all priority files

- Priority 1: Updated SettingsUI.tsx, BasicsTab.tsx, LimitsTab.tsx, AdvancedTab.tsx, RecurringTab.tsx, AvailabilityTab.tsx, event-type-detail.tsx
- Priority 2: Updated edit-availability-hours/name/override/day.tsx, oauth/callback.tsx
- Priority 3: Updated AvailabilityListScreen.tsx with dark mode for modals and list items
- Priority 4: Updated BookingActionsModal.tsx, BookingActionsModal.ios.tsx, GlassModalHeader.tsx, LoginScreen.tsx, LocationsList.tsx, SearchHeader.tsx, toast.tsx

Dark mode colors used:
- Main bg: #000000 (cal.bg.dark)
- Secondary bg: #171717 (cal.bg.secondaryDark)
- Muted bg: #262626 (cal.bg.mutedDark)
- Borders: #4D4D4D (cal.border.dark)
- Secondary text: #A3A3A3 (cal.text.secondaryDark)

* fix(companion): add dark mode support to iOS header, GlassView modals, and main content background

* fix(companion): add dark mode support to toggle/switch components

* fix(companion): use iOS green (#34C759) for toggle ON state in dark mode

* fix(companion): use white track + black thumb for dark mode toggles

* fix(companion): add dark mode support to login page logo and button

* dark mode to many pages, centralize the colors

* dark mode skelaton

* Availability pages android

* toggles

* headers

* red color for dark mode

* copy button

* white flash on bottom ui native ios sheet

* bottom sheet buttons

* bottom sheet in gray color

* bottom sheet again.. yeahh

* toggle green

* toggle green

* fix lint and typechecks

* fix exntension error

* Update companion/components/event-type-detail/tabs/LimitsTabDatePicker.android.tsx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* address cubic comments

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: peer@cal.com <peer@cal.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-01-30 05:54:18 +01:00

484 lines
19 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Image,
Platform,
Text,
TouchableOpacity,
useColorScheme,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, type UserProfile } from "@/services/calcom";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { CalComLogo } from "./CalComLogo";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AppPressable } from "@/components/AppPressable";
import type { EventTypeFilters, EventTypeSortOption } from "@/hooks/useEventTypeFilter";
interface FilterOption {
key: string;
label: string;
}
interface EventTypeFilterConfig {
sortBy: EventTypeSortOption;
filters: EventTypeFilters;
onSortChange: (sort: EventTypeSortOption) => void;
onToggleFilter: (filterKey: keyof EventTypeFilters) => void;
onResetFilters: () => void;
activeFilterCount: number;
}
interface HeaderProps {
/** Optional: Filter options for dropdown menu (e.g., booking status filter) */
filterOptions?: FilterOption[];
/** Optional: Currently active filter key */
activeFilter?: string;
/** Optional: Callback when filter changes */
onFilterChange?: (filterKey: string) => void;
/** Optional: Event type filter/sort config for Android */
eventTypeFilterConfig?: EventTypeFilterConfig;
}
export function Header({
filterOptions,
activeFilter,
onFilterChange,
eventTypeFilterConfig,
}: HeaderProps) {
const router = useRouter();
const insets = useSafeAreaInsets();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
// iOS system colors for dark mode
const colors = {
text: isDark ? "#FFFFFF" : "#000000",
textSecondary: isDark ? "#A3A3A3" : "#666666",
background: isDark ? "#000000" : "#FFFFFF",
border: isDark ? "#4D4D4D" : "#E5E5EA",
logoColor: isDark ? "#FFFFFF" : "#333333",
};
const fetchUserProfile = useCallback(async () => {
try {
const profile = await CalComAPIService.getUserProfile();
setUserProfile(profile);
setLoading(false);
} catch (err) {
console.error("Failed to fetch user profile");
if (__DEV__) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
console.debug("[Header] fetchUserProfile failed", { message, stack });
}
setLoading(false);
}
}, []);
useEffect(() => {
fetchUserProfile();
}, [fetchUserProfile]);
const handleProfile = () => {
// Navigate to profile sheet on all platforms (Android, Web, Extension)
router.push("/profile-sheet");
};
const activeFilterLabel =
filterOptions?.find((opt) => opt.key === activeFilter)?.label ?? "Filter";
return (
<View
className="flex-row items-center justify-between border-b border-[#E5E5EA] bg-white px-2 dark:border-[#4D4D4D] dark:bg-black md:px-4"
style={{ paddingTop: insets.top + 4, paddingBottom: 4 }}
>
{/* Left: Cal.com Logo */}
<View className="ms-1">
<CalComLogo width={101} height={22} color={colors.logoColor} />
</View>
{/* Right: Filter Dropdown + Profile */}
<View
className="flex-row items-center gap-2"
style={Platform.OS === "web" ? { marginRight: 8 } : {}}
>
{/* Booking status filter dropdown (for bookings page) */}
{filterOptions && filterOptions.length > 0 && onFilterChange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppPressable className="h-10 flex-row items-center justify-center gap-2 rounded-full border border-[#E5E5E5] bg-white px-4 dark:border-[#262626] dark:bg-[#171717]">
<Text className="text-[15px] font-medium text-[#000000] dark:text-white">
{activeFilterLabel}
</Text>
</AppPressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={{ top: 60, bottom: 20, left: 12, right: 12 }}
sideOffset={8}
className="w-44"
align="end"
>
{filterOptions.map((option) => {
const isSelected = activeFilter === option.key;
// Map filter keys to appropriate icons
const getFilterIcon = (key: string) => {
switch (key) {
case "upcoming":
return "calendar-outline";
case "unconfirmed":
return "help-circle-outline";
case "recurring":
return "repeat-outline";
case "past":
return "checkmark-circle-outline";
case "cancelled":
return "close-circle-outline";
default:
return "calendar-outline";
}
};
return (
<DropdownMenuItem key={option.key} onPress={() => onFilterChange(option.key)}>
<View className="flex-row items-center gap-2">
<Ionicons
name={isSelected ? "checkmark-circle" : getFilterIcon(option.key)}
size={16}
color={isSelected ? colors.text : colors.textSecondary}
/>
<Text
className={
isSelected
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
{option.label}
</Text>
</View>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Event type filter/sort menu (for event types page - Android and Web/Extension) */}
{eventTypeFilterConfig && (Platform.OS === "android" || Platform.OS === "web") && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppPressable className="relative p-2">
<Ionicons name="options-outline" size={22} color={colors.text} />
{/* Badge for active filters */}
{eventTypeFilterConfig.activeFilterCount > 0 && (
<View
className="absolute -top-0.5 -right-0.5 items-center justify-center rounded-full bg-[#000000] dark:bg-white"
style={{ width: 16, height: 16 }}
>
<Text className="text-[10px] font-bold text-white dark:text-black">
{eventTypeFilterConfig.activeFilterCount}
</Text>
</View>
)}
</AppPressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={{ top: 60, bottom: 20, left: 12, right: 12 }}
sideOffset={8}
className="w-56"
align="end"
>
{/* Sort by Submenu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<View className="flex-row items-center gap-2">
<Ionicons name="swap-vertical-outline" size={16} color={colors.textSecondary} />
<Text className="text-base dark:text-gray-300">Sort by</Text>
</View>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onPress={() => eventTypeFilterConfig.onSortChange("alphabetical")}
>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.sortBy === "alphabetical"
? "checkmark-circle"
: "text-outline"
}
size={16}
color={
eventTypeFilterConfig.sortBy === "alphabetical"
? colors.text
: colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.sortBy === "alphabetical"
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Alphabetical
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onSortChange("newest")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.sortBy === "newest"
? "checkmark-circle"
: "calendar-outline"
}
size={16}
color={
eventTypeFilterConfig.sortBy === "newest"
? colors.text
: colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.sortBy === "newest"
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Newest First
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onSortChange("duration")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.sortBy === "duration"
? "checkmark-circle"
: "time-outline"
}
size={16}
color={
eventTypeFilterConfig.sortBy === "duration"
? colors.text
: colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.sortBy === "duration"
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
By Duration
</Text>
</View>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
{/* Filter Label */}
<DropdownMenuLabel>
<View className="flex-row items-center gap-2">
<Ionicons name="filter-outline" size={16} color={colors.textSecondary} />
<Text className="text-sm font-medium text-gray-500 dark:text-gray-400">
Filters
</Text>
</View>
</DropdownMenuLabel>
{/* Filter Toggles (multi-select) */}
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onToggleFilter("hiddenOnly")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.hiddenOnly
? "checkmark-circle"
: "eye-off-outline"
}
size={16}
color={
eventTypeFilterConfig.filters.hiddenOnly ? colors.text : colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.filters.hiddenOnly
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Hidden Only
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onToggleFilter("paidOnly")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.paidOnly ? "checkmark-circle" : "cash-outline"
}
size={16}
color={
eventTypeFilterConfig.filters.paidOnly ? colors.text : colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.filters.paidOnly
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Paid Events
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onToggleFilter("seatedOnly")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.seatedOnly
? "checkmark-circle"
: "people-outline"
}
size={16}
color={
eventTypeFilterConfig.filters.seatedOnly ? colors.text : colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.filters.seatedOnly
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Seated Events
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem
onPress={() => eventTypeFilterConfig.onToggleFilter("requiresConfirmationOnly")}
>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.requiresConfirmationOnly
? "checkmark-circle"
: "checkmark-circle-outline"
}
size={16}
color={
eventTypeFilterConfig.filters.requiresConfirmationOnly
? colors.text
: colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.filters.requiresConfirmationOnly
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Requires Confirmation
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem
onPress={() => eventTypeFilterConfig.onToggleFilter("recurringOnly")}
>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.recurringOnly
? "checkmark-circle"
: "repeat-outline"
}
size={16}
color={
eventTypeFilterConfig.filters.recurringOnly
? colors.text
: colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.filters.recurringOnly
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Recurring
</Text>
</View>
</DropdownMenuItem>
{/* Clear All - only show when filters are active */}
{eventTypeFilterConfig.activeFilterCount > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onPress={eventTypeFilterConfig.onResetFilters}>
<View className="flex-row items-center gap-2">
<Ionicons name="close-circle-outline" size={16} color="#FF3B30" />
<Text className="text-base text-[#FF3B30]">Clear All Filters</Text>
</View>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Profile Picture */}
<TouchableOpacity onPress={handleProfile} className="p-1">
{loading ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : userProfile?.avatarUrl ? (
<Image
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
className="h-8 w-8 rounded-full"
style={{ width: 32, height: 32, borderRadius: 16 }}
/>
) : (
<View
className="items-center justify-center rounded-full bg-[#E5E5EA] dark:bg-[#4D4D4D]"
style={{ width: 32, height: 32 }}
>
<Ionicons name="person-outline" size={20} color={colors.textSecondary} />
</View>
)}
</TouchableOpacity>
</View>
</View>
);
}