index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. import React, { useCallback, useEffect, useRef, useState } from 'react';
  2. import { useFocusEffect } from '@react-navigation/native';
  3. import { View, Text, FlatList, Alert } from 'react-native';
  4. import { Button, Header, Input, Loading, Modal, PageWrapper } from 'src/components';
  5. import SearchIcon from '../../../../../assets/icons/search.svg';
  6. import { fetchItemsForSeries, usePostSetToggleItem } from '@api/series';
  7. import { Colors } from 'src/theme';
  8. import { ButtonVariants } from 'src/types/components';
  9. import { AccordionListItem } from '../Components/AccordionListItem';
  10. import { styles } from './styles';
  11. import TabViewWrapper from 'src/components/TabViewWrapper';
  12. import { SafeAreaView } from 'react-native-safe-area-context';
  13. interface SeriesItem {
  14. series_id: number;
  15. item_id: number;
  16. icon: string | null;
  17. name: string;
  18. readonly: boolean;
  19. info: string;
  20. new: boolean;
  21. checked: boolean;
  22. checked_double: boolean;
  23. double: boolean;
  24. }
  25. interface SeriesGroup {
  26. name: string;
  27. icon: string;
  28. series_id: number;
  29. series_name: string;
  30. items: SeriesItem[];
  31. }
  32. interface FilteredData {
  33. [key: string]: SeriesGroup[];
  34. }
  35. interface Route {
  36. key: string;
  37. title: string;
  38. }
  39. export const SeriesItemScreen = ({ route }: { route: any }) => {
  40. const { id, name, token, scrollToItemId } = route.params;
  41. const { mutate: updateSeriesItem } = usePostSetToggleItem();
  42. const flatListRef = useRef<FlatList>(null);
  43. const scrolledRef = useRef(false);
  44. const [search, setSearch] = useState<string>('');
  45. const [filteredData, setFilteredData] = useState<FilteredData>({});
  46. const [seriesData, setSeriesData] = useState<SeriesGroup[]>([]);
  47. const [index, setIndex] = useState<number>(0);
  48. const [isLoading, setIsLoading] = useState<boolean>(true);
  49. const [isInfoModalVisible, setIsInfoModalVisible] = useState<boolean>(false);
  50. const [infoItem, setInfoItem] = useState<SeriesItem | null>(null);
  51. const [activeFilteredData, setActiveFilteredData] = useState<SeriesGroup[]>([]);
  52. useFocusEffect(
  53. useCallback(() => {
  54. const fetchGroups = async () => {
  55. const data = await fetchItemsForSeries(String(token), id);
  56. if (!data) {
  57. setIsLoading(false);
  58. return;
  59. }
  60. const allItems = data.groups;
  61. const newItems = data.groups
  62. .map((group) => ({
  63. ...group,
  64. items: group.items.filter((item) => item.new)
  65. }))
  66. .filter((group) => group.items.length > 0);
  67. setFilteredData({ all: allItems, new: newItems, unchecked: [], checked: [] });
  68. setSeriesData(data.groups);
  69. setIsLoading(false);
  70. };
  71. fetchGroups();
  72. }, [])
  73. );
  74. const [routes] = useState([
  75. { key: 'all', title: 'All items' },
  76. { key: 'checked', title: 'Ticked' },
  77. { key: 'unchecked', title: 'Unticked' },
  78. { key: 'new', title: 'New' }
  79. ]);
  80. const handleIndexChange = (index: number) => {
  81. let dataToFilter = [...seriesData];
  82. switch (index) {
  83. case 3:
  84. dataToFilter = dataToFilter
  85. .map((group) => ({
  86. ...group,
  87. items: group.items.filter((item) => item.new)
  88. }))
  89. .filter((group) => group.items.length > 0);
  90. break;
  91. case 2:
  92. dataToFilter = dataToFilter
  93. .map((group) => ({
  94. ...group,
  95. items: group.items.filter(
  96. (item) => !item.checked || (item.double && !item.checked_double)
  97. )
  98. }))
  99. .filter((group) => group.items.length > 0);
  100. break;
  101. case 1:
  102. dataToFilter = dataToFilter
  103. .map((group) => ({
  104. ...group,
  105. items: group.items.filter(
  106. (item) => item.checked || (item.double && item.checked_double)
  107. )
  108. }))
  109. .filter((group) => group.items.length > 0);
  110. break;
  111. default:
  112. break;
  113. }
  114. setActiveFilteredData(dataToFilter);
  115. setFilteredData((prevState) => ({
  116. ...prevState,
  117. [routes[index].key]: dataToFilter
  118. }));
  119. };
  120. useEffect(() => {
  121. handleIndexChange(index);
  122. }, [seriesData, index]);
  123. useEffect(() => {
  124. setActiveFilteredData(filteredData[routes[index].key]);
  125. }, [filteredData]);
  126. const [subItemY, setSubItemY] = useState<number | null>(null);
  127. const [groupY, setGroupY] = useState<number | null>(null);
  128. useEffect(() => {
  129. if (!scrollToItemId || scrolledRef.current || !activeFilteredData?.length) return;
  130. const targetId = String(scrollToItemId);
  131. const groupIndex = activeFilteredData.findIndex((group) =>
  132. group.items.some((item) => String(item.item_id) === targetId)
  133. );
  134. if (groupIndex !== -1) {
  135. if (subItemY !== null && groupY !== null) {
  136. scrolledRef.current = true;
  137. const scroll = () => {
  138. flatListRef.current?.scrollToIndex({
  139. index: groupIndex,
  140. animated: true,
  141. viewPosition: 0,
  142. viewOffset: -subItemY - 60 + 50 // -subItemY to push group up, -60 for header, +50 buffer
  143. });
  144. };
  145. const timer1 = setTimeout(scroll, 300);
  146. const timer2 = setTimeout(scroll, 800);
  147. return () => {
  148. clearTimeout(timer1);
  149. clearTimeout(timer2);
  150. };
  151. } else {
  152. const roughScroll = () => {
  153. flatListRef.current?.scrollToIndex({
  154. index: groupIndex,
  155. animated: false,
  156. viewPosition: 0
  157. });
  158. };
  159. const timer = setTimeout(roughScroll, 600);
  160. return () => clearTimeout(timer);
  161. }
  162. } else {
  163. scrolledRef.current = true;
  164. }
  165. }, [scrollToItemId, activeFilteredData, subItemY, groupY]);
  166. const renderScene = ({ route: tabRoute }: { route: Route }) => {
  167. return isLoading ? (
  168. <Loading />
  169. ) : (
  170. <FlatList
  171. ref={flatListRef}
  172. key={routes[index].key}
  173. keyExtractor={(item, index) => index.toString()}
  174. showsVerticalScrollIndicator={false}
  175. initialNumToRender={15}
  176. windowSize={11}
  177. style={{ paddingTop: 10 }}
  178. contentContainerStyle={{ paddingBottom: 16 }}
  179. data={activeFilteredData}
  180. onScrollToIndexFailed={(info) => {
  181. const offset = info.averageItemLength * info.index;
  182. flatListRef.current?.scrollToOffset({ offset, animated: false });
  183. setTimeout(() => {
  184. flatListRef.current?.scrollToIndex({
  185. index: info.index,
  186. animated: false,
  187. viewPosition: 0
  188. });
  189. }, 150);
  190. }}
  191. renderItem={({ item, index }) => {
  192. const targetId = String(scrollToItemId);
  193. const isTargetGroup = item.items.some((subItem) => String(subItem.item_id) === targetId);
  194. return (
  195. <View
  196. onLayout={(e) => {
  197. if (isTargetGroup) {
  198. const y = e.nativeEvent.layout.y;
  199. setGroupY((prev) => (prev === null ? y : prev));
  200. }
  201. }}
  202. >
  203. <AccordionListItem
  204. item={item}
  205. onCheckboxChange={handleCheckboxChange}
  206. setIsInfoModalVisible={setIsInfoModalVisible}
  207. setInfoItem={setInfoItem}
  208. isSeries={id === -1}
  209. token={token}
  210. highlightItemId={scrollToItemId}
  211. onHighlightLayout={(y) => {
  212. setSubItemY((prev) => (prev === null ? y : prev));
  213. }}
  214. />
  215. </View>
  216. );
  217. }}
  218. />
  219. );
  220. };
  221. useEffect(() => {
  222. const searchText = search.toLowerCase();
  223. const searchData = filteredData[routes[index].key]
  224. ?.map((group) => {
  225. const groupNameMatch = group.name.toLowerCase().includes(searchText);
  226. if (groupNameMatch) {
  227. return group;
  228. } else {
  229. const filteredItems = group.items.filter((item) =>
  230. item.name.toLowerCase().includes(searchText)
  231. );
  232. return filteredItems.length > 0 ? { ...group, items: filteredItems } : null;
  233. }
  234. })
  235. .filter((group) => group !== null);
  236. setActiveFilteredData(searchData as SeriesGroup[]);
  237. }, [search]);
  238. const handleCheckboxChange = useCallback(
  239. async (item: SeriesItem, groupName: string, double?: boolean) => {
  240. setSeriesData((currentData) => {
  241. const newData = currentData.map((group) => {
  242. const hasItem = group.items.some((subItem) => subItem.item_id === item.item_id);
  243. if (!hasItem) return group;
  244. return {
  245. ...group,
  246. items: group.items.map((subItem) =>
  247. subItem.item_id === item.item_id
  248. ? {
  249. ...subItem,
  250. ...(double
  251. ? { checked_double: !subItem.checked_double }
  252. : { checked: !subItem.checked })
  253. }
  254. : subItem
  255. )
  256. };
  257. });
  258. return newData;
  259. });
  260. const itemData = {
  261. token: token,
  262. series_id: item.series_id,
  263. item_id: item.item_id,
  264. checked: double ? (item.checked_double ? 0 : 1) : (item.checked ? 0 : 1) as 0 | 1,
  265. double: (double ? 1 : 0) as 0 | 1
  266. };
  267. try {
  268. updateSeriesItem(itemData);
  269. } catch (error) {
  270. console.error('Failed to update checkbox state', error);
  271. }
  272. },
  273. [token, updateSeriesItem]
  274. );
  275. return (
  276. <SafeAreaView style={{ height: '100%' }} edges={['top']}>
  277. <View style={{ marginLeft: '5%', marginRight: '5%' }}>
  278. <Header label={name} />
  279. <Input
  280. inputMode={'search'}
  281. placeholder={'Search'}
  282. onChange={(text) => setSearch(text)}
  283. value={search}
  284. icon={<SearchIcon fill={'#C8C8C8'} width={14} height={14} />}
  285. />
  286. </View>
  287. <Modal
  288. visible={isInfoModalVisible}
  289. children={
  290. <View style={styles.modalView}>
  291. <Text style={styles.infoTitle}>{infoItem?.name}</Text>
  292. <Text style={styles.infoText}>{infoItem?.info}</Text>
  293. <Button
  294. variant={ButtonVariants.OPACITY}
  295. containerStyles={styles.btnContainer}
  296. textStyles={{
  297. color: Colors.DARK_BLUE
  298. }}
  299. onPress={() => setIsInfoModalVisible(false)}
  300. children={'Got it'}
  301. />
  302. </View>
  303. }
  304. onRequestClose={() => setIsInfoModalVisible(false)}
  305. headerTitle={'Info'}
  306. visibleInPercent={'auto'}
  307. />
  308. {token ? (
  309. <TabViewWrapper
  310. routes={routes}
  311. renderScene={renderScene as never}
  312. setIndex={setIndex}
  313. lazy={false}
  314. selectedIndex={index}
  315. />
  316. ) : (
  317. <View style={{ paddingTop: 8, flex: 1, marginHorizontal: '5%' }}>
  318. {renderScene({ route: routes[index] })}
  319. </View>
  320. )}
  321. </SafeAreaView>
  322. );
  323. };