import { Ionicons } from "@expo/vector-icons"; import { BottomSheetBackdrop, type BottomSheetBackdropProps, BottomSheetModal, BottomSheetScrollView, } from "@gorhom/bottom-sheet"; import { useQuery } from "@tanstack/react-query"; import { isEqual } from "lodash"; import type React from "react"; import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import { StyleSheet, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { Button } from "../Button"; import { Input } from "../common/Input"; import { Loader } from "../Loader"; /** * Config for the filter currently shown by the shared sheet. Generics are erased * at the FilterButton → provider boundary, so item-typed callbacks use `any`. */ export interface FilterConfig { /** Stable identity — changing it remounts the content with fresh state. */ key: string; id: string; queryKey: string; queryFn: (params: any) => Promise; title: string; values: any[]; set: (value: any[]) => void; renderItemLabel: (item: any) => React.ReactNode; searchFilter?: (item: any, query: string) => boolean; disableSearch?: boolean; multiple?: boolean; } const LIMIT = 100; interface SharedFilterSheetProps { modalRef: React.RefObject; open: boolean; setOpen: (open: boolean) => void; config: FilterConfig | null; } /** * The single shared filter sheet — one BottomSheetModal hosted by * FilterSheetProvider for a whole screen; FilterButtons only swap its `config`. * Because only one modal ever exists, rapid taps across buttons can never stack * two sheets, so no guard/timer is needed. The modal shell stays mounted with a * stable ref (present() can run synchronously from the tapping button); the * inner content is keyed by the active filter so its pagination/search reset * cleanly between filters. */ export const SharedFilterSheet: React.FC = ({ modalRef, open, setOpen, config, }) => { const snapPoints = useMemo(() => ["85%"], []); const insets = useSafeAreaInsets(); // Opening is imperative (the provider calls present()); this effect only // closes, and never dismisses a modal that was never presented. const wasPresentedRef = useRef(false); useEffect(() => { if (!open && wasPresentedRef.current) { modalRef.current?.dismiss(); } }, [open, modalRef]); const handleSheetChanges = useCallback( (index: number) => { if (index >= 0) { wasPresentedRef.current = true; } else if (index === -1) { wasPresentedRef.current = false; setOpen(false); } }, [setOpen], ); const requestClose = useCallback(() => setOpen(false), [setOpen]); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( ), [], ); return ( {config && ( )} ); }; interface SharedFilterSheetContentProps { config: FilterConfig; onRequestClose: () => void; } const SharedFilterSheetContent: React.FC = ({ config, onRequestClose, }) => { const { id, queryKey, queryFn, title, values, set, renderItemLabel, searchFilter, disableSearch = false, multiple = false, } = config; const { t } = useTranslation(); // The options query lives here (deduped with the FilterButton's own query via // the shared React Query key), so the list stays live after the sheet opens. const { data: _data, isLoading: loading } = useQuery({ queryKey: ["filters", title, queryKey, id], queryFn, staleTime: 0, enabled: !!id && !!queryFn && !!queryKey, }); const [data, setData] = useState([]); const [offset, setOffset] = useState(0); const [search, setSearch] = useState(""); // Filtering on every keystroke blocks the JS thread on large lists; defer the // value so the keystroke render stays cheap and the list update runs after. const deferredSearch = useDeferredValue(search); const [showSearch, setShowSearch] = useState(false); const filteredData = useMemo(() => { if (!deferredSearch) return _data; const results = []; for (let i = 0; i < (_data?.length || 0); i++) { if (_data && searchFilter?.(_data[i], deferredSearch)) { results.push(_data[i]); } } return results.slice(0, 100); }, [deferredSearch, _data, searchFilter]); useEffect(() => { if (!data || data.length === 0 || disableSearch) return; if (data.length > 15) { setShowSearch(true); } }, [data, disableSearch]); // Loads data in batches of LIMIT from offset (efficient "load more"). useEffect(() => { if (!_data || _data.length === 0) return; const newData = [...data]; for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) { const item = _data[i]; // Option objects are recreated across renders → dedupe by value. const exists = newData.some((existingItem) => isEqual(existingItem, item), ); if (!exists) { newData.push(item); } } setData(newData); }, [offset, _data]); const renderData = useMemo(() => { if (deferredSearch.length > 0 && showSearch) return filteredData; return data; }, [deferredSearch, showSearch, filteredData, data]); const renderedRows = useMemo( () => renderData?.map((item, index) => ( { const isSelected = values.some((value) => isEqual(value, item)); if (multiple) { if (!isSelected) set(values.concat(item)); else set(values.filter((value) => !isEqual(value, item))); setTimeout(() => onRequestClose(), 250); } else if (!isSelected) { set([item]); setTimeout(() => onRequestClose(), 250); } }} className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between' > {renderItemLabel(item)} {values.some((i) => isEqual(i, item)) ? ( ) : ( )} )), [renderData, values, multiple, set, renderItemLabel, onRequestClose], ); return ( <> {title} {loading ? ( ) : ( {t("search.x_items", { count: _data?.length })} )} {showSearch && ( )} {renderedRows} {data.length < (_data?.length || 0) && ( )} ); };