refactor: Move to Pagination Component and Replace Drawer with BottomSheet

This commit is contained in:
Rajarshee Chatterjee
2025-11-14 14:14:41 +05:30
parent a712100cfc
commit daf7bf2ccb
5 changed files with 551 additions and 200 deletions
+110 -145
View File
@@ -1,6 +1,5 @@
import React, { Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { StyleSheet, View, StatusBar, Text, Share } from 'react-native';
import { Drawer } from 'react-native-drawer-layout';
import Animated, {
SlideInUp,
SlideOutUp,
@@ -19,7 +18,6 @@ import NovelScreenLoading from './components/LoadingAnimation/NovelScreenLoading
import { NovelScreenProps } from '@navigators/types';
import { ChapterInfo } from '@database/types';
import { getString } from '@strings/translations';
import NovelDrawer from './components/NovelDrawer';
import { isNumber, noop } from 'lodash-es';
import NovelAppbar from './components/NovelAppbar';
import { resolveUrl } from '@services/plugin/fetch';
@@ -37,14 +35,11 @@ import { LegendListRef } from '@legendapp/list';
const Novel = ({ route, navigation }: NovelScreenProps) => {
const {
pageIndex,
pages,
novel,
chapters,
fetching,
batchInformation,
getNextChapterBatch,
openPage,
setNovel,
bookmarkChapters,
markChaptersRead,
@@ -66,11 +61,6 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
const deleteDownloadsSnackbar = useBoolean();
const headerOpacity = useSharedValue(0);
const {
value: drawerOpen,
setTrue: openDrawer,
setFalse: closeDrawer,
} = useBoolean();
// TODO: fix this
// useEffect(() => {
@@ -242,141 +232,117 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
const styles = useMemo(() => createStyles(theme), [theme]);
return (
<Drawer
open={drawerOpen}
onOpen={openDrawer}
onClose={closeDrawer}
swipeEnabled={pages.length > 1}
hideStatusBarOnOpen={true}
swipeMinVelocity={1000}
drawerStyle={styles.drawer}
renderDrawerContent={() =>
(novel?.totalPages ?? 0) > 1 || pages.length > 1 ? (
<NovelDrawer
theme={theme}
pages={pages}
pageIndex={pageIndex}
openPage={openPage}
closeDrawer={closeDrawer}
/>
) : (
<></>
)
}
>
<Portal.Host>
<View style={[styles.container, { backgroundColor: theme.background }]}>
<Portal>
{selected.length === 0 ? (
<NovelAppbar
novel={novel}
deleteChapters={deleteChs}
downloadChapters={downloadChs}
showEditInfoModal={showEditInfoModal}
setCustomNovelCover={setCustomNovelCover}
downloadCustomChapterModal={openDlChapterModal}
showJumpToChapterModal={showJumpToChapterModal}
shareNovel={shareNovel}
theme={theme}
isLocal={novel?.isLocal ?? route.params?.isLocal}
goBack={navigation.goBack}
headerOpacity={headerOpacity}
/>
) : (
<Animated.View
entering={SlideInUp.duration(250)}
exiting={SlideOutUp.duration(250)}
style={styles.appbar}
>
<Appbar.Action
icon="close"
iconColor={theme.onBackground}
onPress={() => setSelected([])}
/>
<Appbar.Content
title={`${selected.length}`}
titleStyle={{ color: theme.onSurface }}
/>
<Appbar.Action
icon="select-all"
iconColor={theme.onBackground}
onPress={() => {
setSelected(chapters);
}}
/>
</Animated.View>
)}
</Portal>
<SafeAreaView excludeTop>
<Suspense fallback={<NovelScreenLoading theme={theme} />}>
<NovelScreenList
headerOpacity={headerOpacity}
listRef={chapterListRef}
navigation={navigation}
openDrawer={openDrawer}
routeBaseNovel={route.params}
selected={selected}
setSelected={setSelected}
getNextChapterBatch={
batchInformation.batch < batchInformation.total && !fetching
? getNextChapterBatch
: noop
}
/>
</Suspense>
</SafeAreaView>
<Portal>
<Actionbar active={selected.length > 0} actions={actions} />
<Snackbar
visible={deleteDownloadsSnackbar.value}
onDismiss={deleteDownloadsSnackbar.setFalse}
action={{
label: getString('common.delete'),
onPress: () => {
deleteChapters(chapters.filter(c => c.isDownloaded));
},
}}
theme={{ colors: { primary: theme.primary } }}
style={styles.snackbar}
<Portal.Host>
<View style={[styles.container, { backgroundColor: theme.background }]}>
<Portal>
{selected.length === 0 ? (
<NovelAppbar
novel={novel}
deleteChapters={deleteChs}
downloadChapters={downloadChs}
showEditInfoModal={showEditInfoModal}
setCustomNovelCover={setCustomNovelCover}
downloadCustomChapterModal={openDlChapterModal}
showJumpToChapterModal={showJumpToChapterModal}
shareNovel={shareNovel}
theme={theme}
isLocal={novel?.isLocal ?? route.params?.isLocal}
goBack={navigation.goBack}
headerOpacity={headerOpacity}
/>
) : (
<Animated.View
entering={SlideInUp.duration(250)}
exiting={SlideOutUp.duration(250)}
style={styles.appbar}
>
<Text style={{ color: theme.onSurface }}>
{getString('novelScreen.deleteMessage')}
</Text>
</Snackbar>
</Portal>
<Portal>
{novel && (
<>
<JumpToChapterModal
modalVisible={jumpToChapterModal}
hideModal={() => showJumpToChapterModal(false)}
chapters={chapters}
novel={novel}
chapterListRef={chapterListRef}
navigation={navigation}
/>
<EditInfoModal
modalVisible={editInfoModal}
hideModal={() => showEditInfoModal(false)}
novel={novel}
setNovel={setNovel}
theme={theme}
/>
<DownloadCustomChapterModal
modalVisible={dlChapterModalVisible}
hideModal={closeDlChapterModal}
novel={novel}
chapters={chapters}
theme={theme}
downloadChapters={downloadChapters}
/>
</>
)}
</Portal>
</View>
</Portal.Host>
</Drawer>
<Appbar.Action
icon="close"
iconColor={theme.onBackground}
onPress={() => setSelected([])}
/>
<Appbar.Content
title={`${selected.length}`}
titleStyle={{ color: theme.onSurface }}
/>
<Appbar.Action
icon="select-all"
iconColor={theme.onBackground}
onPress={() => {
setSelected(chapters);
}}
/>
</Animated.View>
)}
</Portal>
<SafeAreaView excludeTop>
<Suspense fallback={<NovelScreenLoading theme={theme} />}>
<NovelScreenList
headerOpacity={headerOpacity}
listRef={chapterListRef}
navigation={navigation}
routeBaseNovel={route.params}
selected={selected}
setSelected={setSelected}
getNextChapterBatch={
batchInformation.batch < batchInformation.total && !fetching
? getNextChapterBatch
: noop
}
/>
</Suspense>
</SafeAreaView>
<Portal>
<Actionbar active={selected.length > 0} actions={actions} />
<Snackbar
visible={deleteDownloadsSnackbar.value}
onDismiss={deleteDownloadsSnackbar.setFalse}
action={{
label: getString('common.delete'),
onPress: () => {
deleteChapters(chapters.filter(c => c.isDownloaded));
},
}}
theme={{ colors: { primary: theme.primary } }}
style={styles.snackbar}
>
<Text style={{ color: theme.onSurface }}>
{getString('novelScreen.deleteMessage')}
</Text>
</Snackbar>
</Portal>
<Portal>
{novel && (
<>
<JumpToChapterModal
modalVisible={jumpToChapterModal}
hideModal={() => showJumpToChapterModal(false)}
chapters={chapters}
novel={novel}
chapterListRef={chapterListRef}
navigation={navigation}
/>
<EditInfoModal
modalVisible={editInfoModal}
hideModal={() => showEditInfoModal(false)}
novel={novel}
setNovel={setNovel}
theme={theme}
/>
<DownloadCustomChapterModal
modalVisible={dlChapterModalVisible}
hideModal={closeDlChapterModal}
novel={novel}
chapters={chapters}
theme={theme}
downloadChapters={downloadChapters}
/>
</>
)}
</Portal>
</View>
</Portal.Host>
);
};
@@ -395,7 +361,6 @@ function createStyles(theme: ThemeColors) {
width: '100%',
},
container: { flex: 1 },
drawer: { backgroundColor: 'transparent' },
rowBack: {
alignItems: 'center',
flex: 1,
@@ -52,8 +52,10 @@ interface NovelInfoHeaderProps {
novel: NovelData | (Omit<NovelData, 'id'> & { id: 'NO_ID' });
novelBottomSheetRef: React.RefObject<BottomSheetModalMethods | null>;
onRefreshPage: (page: string) => void;
openDrawer: () => void;
page?: string;
pageIndex: number;
pages: string[];
pageNavigationSheetRef: React.RefObject<BottomSheetModalMethods | null>;
setCustomNovelCover: () => Promise<void>;
saveNovelCover: () => Promise<void>;
theme: ThemeColors;
@@ -82,9 +84,6 @@ const NovelInfoHeader = ({
navigation,
novel,
novelBottomSheetRef,
onRefreshPage,
openDrawer,
page,
setCustomNovelCover,
saveNovelCover,
theme,
@@ -227,46 +226,29 @@ const NovelInfoHeader = ({
{isLoading && (!novel.genres || !novel.summary) ? (
<VerticalBarSkeleton />
) : (
<Pressable
style={styles.bottomsheet}
onPress={() =>
page ? openDrawer() : novelBottomSheetRef.current?.present()
}
android_ripple={{
color: color(theme.primary).alpha(0.12).string(),
}}
>
<View style={styles.flex}>
{page || novel.totalPages ? (
<Text
numberOfLines={2}
style={[{ color: theme.onSurface }, styles.pageTitle]}
>
Page: {page ?? ''}
</Text>
) : null}
<Text style={[{ color: theme.onSurface }, styles.chapters]}>
{!fetching || totalChapters !== undefined
? `${totalChapters} ${getString('novelScreen.chapters')}`
: getString('common.loading')}
</Text>
</View>
{page && Number(page) ? (
<IconButton
icon="reload"
iconColor={theme.onSurface}
size={24}
onPress={() => onRefreshPage(page)}
/>
) : null}
<IconButton
icon="filter-variant"
iconColor={filter ? filterColor(theme.isDark) : theme.onSurface}
size={24}
<View style={styles.bottomsheetContainer}>
<Pressable
style={styles.bottomsheet}
onPress={() => novelBottomSheetRef.current?.present()}
/>
</Pressable>
android_ripple={{
color: color(theme.primary).alpha(0.12).string(),
}}
>
<View style={styles.flex}>
<Text style={[{ color: theme.onSurface }, styles.chapters]}>
{!fetching || totalChapters !== undefined
? `${totalChapters} ${getString('novelScreen.chapters')}`
: getString('common.loading')}
</Text>
</View>
<IconButton
icon="filter-variant"
iconColor={filter ? filterColor(theme.isDark) : theme.onSurface}
size={24}
onPress={() => novelBottomSheetRef.current?.present()}
/>
</Pressable>
</View>
)}
</>
</>
@@ -281,16 +263,16 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
paddingRight: 12,
paddingVertical: 4,
paddingVertical: 8,
},
bottomsheetContainer: {
gap: 12,
},
chapters: {
fontSize: 14,
paddingHorizontal: 16,
},
flex: { flex: 1 },
infoItem: {
marginVertical: 2,
},
marginRight: { marginRight: 4 },
novelDetails: {
flex: 1,
@@ -299,8 +281,4 @@ const styles = StyleSheet.create({
paddingBottom: 16,
paddingLeft: 12,
},
pageTitle: {
fontSize: 16,
paddingHorizontal: 16,
},
});
@@ -18,11 +18,13 @@ import {
NativeScrollEvent,
RefreshControl,
StyleSheet,
View,
} from 'react-native';
import { SharedValue } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import TrackSheet from './Tracker/TrackSheet';
import NovelBottomSheet from './NovelBottomSheet';
import PageNavigationBottomSheet from './PageNavigationBottomSheet';
import * as Haptics from 'expo-haptics';
import { AnimatedFAB } from 'react-native-paper';
import { ChapterListSkeleton } from '@components/Skeleton/Skeleton';
@@ -32,12 +34,12 @@ import { LegendList, LegendListRef } from '@legendapp/list';
import FileManager from '@specs/NativeFile';
import { downloadFile } from '@plugins/helpers/fetch';
import { StorageAccessFramework } from 'expo-file-system/legacy';
import PagePaginationControl from './PagePaginationControl';
type NovelScreenListProps = {
headerOpacity: SharedValue<number>;
listRef: React.RefObject<LegendListRef | null>;
navigation: any;
openDrawer: () => void;
selected: ChapterInfo[];
setSelected: React.Dispatch<React.SetStateAction<ChapterInfo[]>>;
getNextChapterBatch: () => void;
@@ -55,7 +57,6 @@ const NovelScreenList = ({
headerOpacity,
listRef,
navigation,
openDrawer,
routeBaseNovel,
selected,
setSelected,
@@ -77,6 +78,7 @@ const NovelScreenList = ({
novel: fetchedNovel,
batchInformation,
pageIndex,
openPage,
} = useNovelContext();
const { pluginId } = routeBaseNovel;
@@ -113,6 +115,7 @@ const NovelScreenList = ({
const novelBottomSheetRef = useRef<BottomSheetModalMethods>(null);
const trackerSheetRef = useRef<BottomSheetModalMethods>(null);
const pageNavigationSheetRef = useRef<BottomSheetModalMethods>(null);
const deleteDownloadsSnackbar = useBoolean();
@@ -306,6 +309,8 @@ const NovelScreenList = ({
}
};
const hasMultiplePages = pages.length > 1 || (novel?.totalPages ?? 0) > 1;
return (
<>
<LegendList
@@ -321,7 +326,23 @@ const NovelScreenList = ({
downloadQueue.length,
]}
// ListEmptyComponent={ListEmptyComponent}
ListFooterComponent={!fetching ? undefined : ListEmptyComponent}
ListFooterComponent={
!fetching ? (
hasMultiplePages ? (
<View style={styles.pageNavigationWrapper}>
<PagePaginationControl
currentPage={pageIndex + 1}
totalPages={pages.length}
onPageChange={pageNum => openPage(pageNum - 1)}
onOpenDrawer={() => pageNavigationSheetRef.current?.present()}
theme={theme}
/>
</View>
) : undefined
) : (
ListEmptyComponent
)
}
renderItem={({ item, index }) => {
if (novel.id === 'NO_ID') {
return null;
@@ -369,8 +390,10 @@ const NovelScreenList = ({
novel={novel}
novelBottomSheetRef={novelBottomSheetRef}
onRefreshPage={onRefreshPage}
openDrawer={openDrawer}
page={pages.length > 1 ? pages[pageIndex] : undefined}
pageIndex={pageIndex}
pages={pages}
pageNavigationSheetRef={pageNavigationSheetRef}
setCustomNovelCover={setCustomNovelCover}
saveNovelCover={saveNovelCover}
theme={theme}
@@ -391,6 +414,15 @@ const NovelScreenList = ({
showChapterTitles={showChapterTitles}
/>
<TrackSheet bottomSheetRef={trackerSheetRef} novel={novel} />
{(novel.totalPages ?? 0) > 1 || pages.length > 1 ? (
<PageNavigationBottomSheet
bottomSheetRef={pageNavigationSheetRef}
theme={theme}
pages={pages}
pageIndex={pageIndex}
openPage={openPage}
/>
) : null}
{showScrollToTop && (
<AnimatedFAB
style={[
@@ -0,0 +1,144 @@
import React from 'react';
import { StyleSheet, View, Pressable, Text } from 'react-native';
import { BottomSheetScrollView, BottomSheetView } from '@gorhom/bottom-sheet';
import { LegendList, LegendListRenderItemProps } from '@legendapp/list';
import color from 'color';
import BottomSheet from '@components/BottomSheet/BottomSheet';
import { ThemeColors } from '@theme/types';
import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { overlay } from 'react-native-paper';
interface PageNavigationBottomSheetProps {
bottomSheetRef: React.RefObject<BottomSheetModalMethods | null>;
theme: ThemeColors;
pages: string[];
pageIndex: number;
openPage: (index: number) => void;
}
export default function PageNavigationBottomSheet({
bottomSheetRef,
theme,
pages,
pageIndex,
openPage,
}: PageNavigationBottomSheetProps) {
const insets = useSafeAreaInsets();
const { left, right } = insets;
const renderItem = ({ item, index }: LegendListRenderItemProps<string>) => {
const isSelected = index === pageIndex;
return (
<View
style={[
styles.pageItemContainer,
{
backgroundColor: isSelected
? theme.isDark
? color(theme.primary).alpha(0.2).string()
: color(theme.primaryContainer).alpha(0.5).string()
: 'transparent',
},
]}
>
<Pressable
android_ripple={{
color: isSelected
? color(theme.primary).alpha(0.2).string()
: theme.rippleColor,
}}
style={styles.pageItem}
onPress={() => {
openPage(index);
bottomSheetRef.current?.close();
}}
>
<View style={styles.pageItemContent}>
<Text
style={[
styles.pageText,
{
color: isSelected ? theme.primary : theme.onSurfaceVariant,
},
]}
>
Page {item}
</Text>
</View>
</Pressable>
</View>
);
};
return (
<BottomSheet
bottomSheetRef={bottomSheetRef}
snapPoints={[Math.min(400, pages.length * 56 + 100)]}
backgroundStyle={styles.transparent}
>
<BottomSheetView
style={[
styles.contentContainer,
{
backgroundColor: overlay(2, theme.surface),
marginLeft: left,
marginRight: right,
paddingBottom: insets.bottom,
},
]}
>
<BottomSheetScrollView>
<LegendList
data={pages}
recycleItems
extraData={pageIndex}
renderItem={renderItem}
estimatedItemSize={56}
contentContainerStyle={styles.listContent}
/>
</BottomSheetScrollView>
</BottomSheetView>
</BottomSheet>
);
}
const styles = StyleSheet.create({
contentContainer: {
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
flex: 1,
maxHeight: 400,
},
listContent: {
paddingBottom: 8,
paddingTop: 4,
},
pageItem: {
flex: 1,
justifyContent: 'center',
minHeight: 56,
paddingHorizontal: 24,
paddingVertical: 16,
},
pageItemContainer: {
borderRadius: 0,
marginHorizontal: 0,
overflow: 'hidden',
},
pageItemContent: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
},
pageText: {},
selectedIndicator: {
borderRadius: 2,
height: 20,
width: 3,
},
transparent: {
backgroundColor: 'transparent',
},
});
@@ -0,0 +1,232 @@
import React, { useMemo } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { IconButton } from 'react-native-paper';
import color from 'color';
import { ThemeColors } from '@theme/types';
import { Row } from '@components/Common';
import { borderColor } from '@theme/colors';
interface PagePaginationControlProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
onOpenDrawer: () => void;
theme: ThemeColors;
}
const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
currentPage,
totalPages,
onPageChange,
onOpenDrawer,
theme,
}) => {
const pageNumbers = useMemo(() => {
const pages: (number | 'ellipsis')[] = [];
if (totalPages <= 3) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage !== 1) {
pages.push(1);
}
const leftPage = currentPage - 1;
if (leftPage > 1) {
if (leftPage > 2) {
pages.push('ellipsis');
}
pages.push(leftPage);
}
pages.push(currentPage);
if (currentPage < totalPages - 1) {
pages.push('ellipsis');
}
if (totalPages !== currentPage) {
pages.push(totalPages);
}
}
return pages;
}, [currentPage, totalPages]);
const canGoPrevious = currentPage > 1;
const canGoNext = currentPage < totalPages;
const handlePrevious = () => {
if (canGoPrevious) {
onPageChange(currentPage - 1);
}
};
const handleNext = () => {
if (canGoNext) {
onPageChange(currentPage + 1);
}
};
const handlePagePress = (page: number) => {
if (page !== currentPage) {
onPageChange(page);
}
};
return (
<View style={styles.container}>
<Pressable
style={[
styles.button,
styles.navButton,
{
borderColor: borderColor,
backgroundColor: theme.surface,
},
!canGoPrevious && styles.disabledButton,
]}
onPress={handlePrevious}
disabled={!canGoPrevious}
android_ripple={{ color: theme.rippleColor }}
>
<IconButton
icon="chevron-left"
iconColor={canGoPrevious ? theme.onSurface : theme.onSurfaceDisabled}
size={20}
style={styles.iconButton}
/>
</Pressable>
<Row style={styles.pageNumbersRow}>
{pageNumbers.map((page, index) => {
if (page === 'ellipsis') {
return (
<Pressable
key={`ellipsis-${index}`}
style={[
styles.button,
styles.ellipsisButton,
{
borderColor: borderColor,
backgroundColor: theme.surface,
},
]}
onPress={onOpenDrawer}
android_ripple={{ color: theme.rippleColor }}
>
<Text style={[styles.ellipsisText, { color: theme.onSurface }]}>
...
</Text>
</Pressable>
);
}
const isActive = page === currentPage;
return (
<Pressable
key={page}
style={[
styles.button,
{
borderColor: isActive ? 'transparent' : borderColor,
backgroundColor: isActive ? theme.primary : theme.surface,
},
]}
onPress={() => handlePagePress(page)}
android_ripple={{
color: isActive
? color(theme.onPrimary).alpha(0.2).string()
: theme.rippleColor,
}}
>
<Text
style={[
styles.pageText,
{
color: isActive ? theme.onPrimary : theme.onSurface,
fontWeight: isActive ? '600' : '400',
},
]}
>
{page}
</Text>
</Pressable>
);
})}
</Row>
<Pressable
style={[
styles.button,
styles.navButton,
{
borderColor: borderColor,
backgroundColor: theme.surface,
},
!canGoNext && styles.disabledButton,
]}
onPress={handleNext}
disabled={!canGoNext}
android_ripple={{ color: theme.rippleColor }}
>
<IconButton
icon="chevron-right"
iconColor={canGoNext ? theme.onSurface : theme.onSurfaceDisabled}
size={20}
style={styles.iconButton}
/>
</Pressable>
</View>
);
};
const styles = StyleSheet.create({
button: {
alignItems: 'center',
borderRadius: 8,
borderWidth: 1,
height: 40,
justifyContent: 'center',
minWidth: 40,
paddingHorizontal: 12,
},
container: {
flex: 1,
alignItems: 'center',
flexDirection: 'row',
gap: 8,
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 12,
},
disabledButton: {
opacity: 0.5,
},
ellipsisButton: {
borderStyle: 'dashed',
},
ellipsisText: {
fontSize: 16,
fontWeight: '500',
},
iconButton: {
margin: 0,
},
navButton: {
paddingHorizontal: 0,
},
pageNumbersRow: {
alignItems: 'center',
flexDirection: 'row',
gap: 8,
},
pageText: {
fontSize: 15,
letterSpacing: 0.15,
},
});
export default PagePaginationControl;