import { Ionicons } from "@expo/vector-icons"; import { BottomSheetBackdrop, type BottomSheetBackdropProps, BottomSheetModal, BottomSheetView, } from "@gorhom/bottom-sheet"; import type React from "react"; import { useCallback, useEffect, useRef } from "react"; import { Platform, StyleSheet, TouchableOpacity, View, type ViewProps, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; // Conditional import for Expo UI (iOS only) let ContextMenu: any = null; let Host: any = null; let Button: any = null; let Picker: any = null; if (!Platform.isTV && Platform.OS === "ios") { try { const ExpoUI = require("@expo/ui/swift-ui"); ContextMenu = ExpoUI.ContextMenu; Host = ExpoUI.Host; Button = ExpoUI.Button; Picker = ExpoUI.Picker; } catch { console.warn( "Expo UI not available, falling back to Android implementation", ); } } // Core option types export type OptionType = | "radio" | "checkbox" | "toggle" | "action" | "separator"; // Base option interface export interface BaseOption { id: string; type: OptionType; label: string; disabled?: boolean; hidden?: boolean; } // Specific option types export interface RadioOption extends BaseOption { type: "radio"; groupId: string; // For grouping radio buttons selected?: boolean; } export interface CheckboxOption extends BaseOption { type: "checkbox"; checked: boolean; } export interface ToggleOption extends BaseOption { type: "toggle"; value: boolean; } export interface ActionOption extends BaseOption { type: "action"; icon?: string; // Ionicons name } export interface SeparatorOption extends BaseOption { type: "separator"; label: ""; // Separator doesn't need a label } // Union type for all options export type Option = | RadioOption | CheckboxOption | ToggleOption | ActionOption | SeparatorOption; // Option groups export interface OptionGroup { id: string; title: string; options: Option[]; } // Component props interface export interface PlatformOptionsMenuProps extends ViewProps { // Data groups: OptionGroup[]; // Presentation trigger: React.ReactNode; // Custom trigger button title: string; // Behavior open: boolean; onOpenChange: (open: boolean) => void; onOptionSelect: (optionId: string, value?: any) => void; // Platform specific configurations expoUIConfig?: { hostStyle?: ViewProps["style"]; }; bottomSheetConfig?: { snapPoints?: string[]; enableDynamicSizing?: boolean; enablePanDownToClose?: boolean; }; } // Helper component for Android bottom sheet option rendering const AndroidOptionItem: React.FC<{ option: Option; onSelect: (optionId: string, value?: any) => void; isLast?: boolean; }> = ({ option, onSelect, isLast }) => { if (option.hidden) return null; if (option.type === "separator") { return ( ); } const handlePress = () => { if (option.disabled) return; switch (option.type) { case "radio": onSelect(option.id, !option.selected); break; case "checkbox": onSelect(option.id, !option.checked); break; case "toggle": onSelect(option.id, !option.value); break; case "action": onSelect(option.id); break; } }; const renderIcon = () => { switch (option.type) { case "radio": return ( ); case "checkbox": return ( ); case "toggle": return ( ); case "action": return option.icon ? ( ) : null; default: return null; } }; return ( <> {option.label} {renderIcon()} {!isLast && ( )} ); }; // Helper component for Android bottom sheet group rendering const AndroidOptionGroup: React.FC<{ group: OptionGroup; onSelect: (optionId: string, value?: any) => void; isLast?: boolean; }> = ({ group, onSelect, isLast }) => { const visibleOptions = group.options.filter((option) => !option.hidden); if (visibleOptions.length === 0) return null; return ( {group.title} {visibleOptions.map((option, index) => ( ))} ); }; /** * PlatformOptionsMenu Component * * A unified component that renders platform-appropriate option menus: * - iOS: Expo UI ContextMenu with native SwiftUI integration * - Android: Bottom sheet modal * * Supports radio buttons, checkboxes, toggles, actions, and separators. */ export const PlatformOptionsMenu: React.FC = ({ groups, trigger, title, open, onOpenChange, onOptionSelect, expoUIConfig, bottomSheetConfig, ...viewProps }) => { const isIOS = Platform.OS === "ios"; const isTv = Platform.isTV; const bottomSheetModalRef = useRef(null); const insets = useSafeAreaInsets(); // Bottom sheet effects useEffect(() => { if (!isIOS) { if (open) bottomSheetModalRef.current?.present(); else bottomSheetModalRef.current?.dismiss(); } }, [open, isIOS]); const handleSheetChanges = useCallback( (index: number) => { if (index === -1) { onOpenChange(false); } }, [onOpenChange], ); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( ), [], ); if (isTv) return null; // iOS Implementation with Expo UI ContextMenu if (isIOS && ContextMenu && Host && Button) { const renderContextMenuItems = () => { const items: React.ReactNode[] = []; groups.forEach((group) => { // Add group items group.options.forEach((option) => { if (option.hidden) return; if (option.type === "separator") { return; } if (option.type === "radio") { // For radio options, create a picker if multiple options in the same group const groupOptions = groups .flatMap((g) => g.options) .filter( (opt) => opt.type === "radio" && (opt as RadioOption).groupId === (option as RadioOption).groupId, ); if (groupOptions.length > 1) { // Create a picker for radio group const selectedIndex = groupOptions.findIndex( (opt) => (opt as RadioOption).selected, ); const pickerOptions = groupOptions.map((opt) => opt.label); items.push( { const selectedOption = groupOptions[index]; if (selectedOption) { onOptionSelect(selectedOption.id, true); } }} />, ); return; // Skip individual radio buttons when we have a picker } } // For other option types, create buttons const systemImage = option.type === "action" && (option as ActionOption).icon ? (option as ActionOption).icon : undefined; const variant = (() => { if (option.type === "checkbox") { return (option as CheckboxOption).checked ? "filled" : "bordered"; } if (option.type === "toggle") { return (option as ToggleOption).value ? "filled" : "bordered"; } return "bordered"; })(); items.push( , ); }); }); return items; }; return ( {renderContextMenuItems()} {trigger} ); } // Android Implementation with Bottom Sheet return ( {title} {groups.map((group, index) => ( ))} ); };