From 3785dddebcc2f899bc308f7cb7b4eaa4019c61a0 Mon Sep 17 00:00:00 2001 From: Rajarshee Chatterjee Date: Wed, 22 Oct 2025 19:49:45 +0530 Subject: [PATCH] feat: Add Drag and Drop Support in Categories Screen Closes #1592 --- package-lock.json | 15 ++ package.json | 1 + src/screens/Categories/CategoriesScreen.tsx | 81 ++++++----- .../Categories/components/CategoryCard.tsx | 135 +++++++++--------- src/screens/library/hooks/useLibrary.ts | 13 +- src/screens/novel/components/NovelAppbar.tsx | 2 +- 6 files changed, 137 insertions(+), 110 deletions(-) diff --git a/package-lock.json b/package-lock.json index 763f306d..4297f044 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "react-native": "0.79.2", "react-native-background-actions": "^4.0.1", "react-native-device-info": "^14.0.4", + "react-native-draggable-flatlist": "^4.0.2", "react-native-drawer-layout": "^4.1.8", "react-native-edge-to-edge": "^1.6.0", "react-native-error-boundary": "^2.0.0", @@ -13381,6 +13382,20 @@ "@babel/runtime": "^7.20.6" } }, + "node_modules/react-native-draggable-flatlist": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.3.tgz", + "integrity": "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw==", + "license": "MIT", + "dependencies": { + "@babel/preset-typescript": "^7.17.12" + }, + "peerDependencies": { + "react-native": ">=0.64.0", + "react-native-gesture-handler": ">=2.0.0", + "react-native-reanimated": ">=2.8.0" + } + }, "node_modules/react-native-drawer-layout": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.1.8.tgz", diff --git a/package.json b/package.json index d14bcb79..eea66ff2 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "react-native": "0.79.2", "react-native-background-actions": "^4.0.1", "react-native-device-info": "^14.0.4", + "react-native-draggable-flatlist": "^4.0.2", "react-native-drawer-layout": "^4.1.8", "react-native-edge-to-edge": "^1.6.0", "react-native-error-boundary": "^2.0.0", diff --git a/src/screens/Categories/CategoriesScreen.tsx b/src/screens/Categories/CategoriesScreen.tsx index b6d0411e..433f4db3 100644 --- a/src/screens/Categories/CategoriesScreen.tsx +++ b/src/screens/Categories/CategoriesScreen.tsx @@ -1,7 +1,10 @@ -import { FlatList, StyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native'; import React, { useEffect } from 'react'; import { FAB } from 'react-native-paper'; import { useNavigation } from '@react-navigation/native'; +import DraggableFlatList, { + RenderItemParams, +} from 'react-native-draggable-flatlist'; import { Appbar, EmptyView, SafeAreaView } from '@components/index'; import AddCategoryModal from './components/AddCategoryModal'; @@ -12,10 +15,10 @@ import { useTheme } from '@hooks/persisted'; import { getString } from '@strings/translations'; import CategoryCard from './components/CategoryCard'; -import { orderBy } from 'lodash-es'; import CategorySkeletonLoading from './components/CategorySkeletonLoading'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useLibraryContext } from '@components/Context/LibraryContext'; +import { ExtendedCategory } from '@screens/library/hooks/useLibrary'; const CategoriesScreen = () => { const { categories, setCategories, refreshCategories, isLoading } = @@ -35,35 +38,41 @@ const CategoriesScreen = () => { refreshCategories(); }, [refreshCategories]); - const updateCategorySort = (currentIndex: number, newIndex: number) => { - // Do not set local as default one - if ( - (newIndex === 0 && - currentIndex === 1 && - categories?.[currentIndex].id === 2) || - (newIndex === 1 && currentIndex === 0 && categories?.[newIndex].id === 2) - ) { + const userCategories = React.useMemo(() => { + if (!categories || categories.length === 0) { + return []; + } + + return categories.filter(cat => cat.id !== 1); + }, [categories]); + + const onDragEnd = ({ data }: { data: ExtendedCategory[] }) => { + if (!categories || categories.length === 0) { return; } - const updatedOrderCategories = orderBy( - categories?.map((category, index) => { - // + 1 because in db, sort start from 1 - // swap adjacent cards - if (index === currentIndex) { - return { ...category, sort: newIndex + 1 }; - } - if (index === newIndex) { - return { ...category, sort: currentIndex + 1 }; - } - return category; - }), - 'sort', - ); - setCategories(updatedOrderCategories); - updateCategoryOrderInDb(updatedOrderCategories || []); + const systemCategories = categories.filter(cat => cat.id === 1); + + const updatedOrderCategories = [...systemCategories, ...data]; + requestAnimationFrame(() => { + setCategories(updatedOrderCategories); + updateCategoryOrderInDb(updatedOrderCategories); + }); }; + const renderItem = ({ + item, + drag, + isActive, + }: RenderItemParams) => ( + + ); + return ( { {isLoading ? ( ) : ( - ( - - )} + renderItem={renderItem} + keyExtractor={(item, index) => `${item.id}-${index}`} + onDragEnd={onDragEnd} + activationDistance={10} + autoscrollSpeed={100} ListEmptyComponent={ Promise; - updateCategorySort: (currentIndex: number, newIndex: number) => void; - totalCategories: number; + drag: () => void; + isActive: boolean; } const CategoryCard: React.FC = ({ category, getCategories, - categoryIndex, - updateCategorySort, - totalCategories, + drag, + isActive, }) => { const theme = useTheme(); @@ -38,80 +37,76 @@ const CategoryCard: React.FC = ({ setFalse: closeDeleteCategoryModal, } = useBoolean(); - const fontWeight = category.sort === 1 ? '700' : '300'; - return ( <> - - - - {category.name} - - - updateCategorySort(categoryIndex, categoryIndex - 1)} - theme={theme} - disabled={ - categoryIndex <= 0 || (categoryIndex === 1 && category.id === 2) - } - /> - updateCategorySort(categoryIndex, categoryIndex + 1)} - theme={theme} - disabled={categoryIndex + 1 >= totalCategories} - /> + + + + + + {category.name} + + {category.id === 2 && ( + + System + + )} + - - {categoryIndex !== 0 && category.id !== 2 ? ( + + + + - ) : null} + @@ -142,12 +137,14 @@ const styles = StyleSheet.create({ }, cardCtn: { borderRadius: 12, - boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.25)', marginBottom: 8, marginHorizontal: 16, paddingHorizontal: 8, paddingVertical: 8, }, + dragHandle: { + marginRight: 4, + }, flex: { flex: 1, }, @@ -155,17 +152,15 @@ const styles = StyleSheet.create({ marginLeft: 16, }, name: { - marginHorizontal: 16, + marginLeft: 16, + marginRight: 8, }, nameCtn: { alignItems: 'center', - flex: 1, + flexGrow: 1, flexDirection: 'row', marginLeft: 8, paddingRight: 16, paddingVertical: 4, }, - orderDownBtn: { - marginLeft: 8, - }, }); diff --git a/src/screens/library/hooks/useLibrary.ts b/src/screens/library/hooks/useLibrary.ts index bce2d768..96b73e91 100644 --- a/src/screens/library/hooks/useLibrary.ts +++ b/src/screens/library/hooks/useLibrary.ts @@ -45,7 +45,18 @@ export const useLibrary = (): UseLibraryReturnType => { novelIds: (c.novelIds ?? '').split(',').map(Number), })); - setCategories(res); + const filteredCategories = res.filter((cat, index) => { + if (index !== 0) { + return true; + } + + const hasUserCategories = res.length > 2; + const hasNovels = cat.novelIds.length > 0 && cat.novelIds[0] !== 0; + + return !hasUserCategories || hasNovels; + }); + + setCategories(filteredCategories); }, []); const getLibrary = useCallback(async () => { diff --git a/src/screens/novel/components/NovelAppbar.tsx b/src/screens/novel/components/NovelAppbar.tsx index beece940..628d728f 100644 --- a/src/screens/novel/components/NovelAppbar.tsx +++ b/src/screens/novel/components/NovelAppbar.tsx @@ -12,7 +12,7 @@ import Animated, { useAnimatedStyle, } from 'react-native-reanimated'; import ExportNovelAsEpubButton from './ExportNovelAsEpubButton'; -import { ChapterInfo, NovelInfo } from '@database/types'; +import { NovelInfo } from '@database/types'; import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; import { MaterialDesignIconName } from '@type/icon';