Compare commits

...

95 Commits

Author SHA1 Message Date
Rajarshee Chatterjee 4b5ee1667c Release v1.0.19 2021-05-31 22:54:49 +05:30
Rajarshee Chatterjee d5b3915ef1 Minor fixes 2021-05-31 21:20:17 +05:30
Rajarshee Chatterjee 7b41f10dd6 Add empty view component 2021-05-31 21:19:29 +05:30
Rajarshee Chatterjee b12eef2699 Global search from novel name 2021-05-31 21:18:26 +05:30
Rajarshee Chatterjee 7a8c8ded24 Add option to select text 2021-05-31 21:16:41 +05:30
Rajarshee Chatterjee b6e8b0057b Use tabs in chapter filter sheet 2021-05-31 17:08:48 +05:30
Rajarshee Chatterjee c06ce93581 Global search from library 2021-05-31 16:00:48 +05:30
Rajarshee Chatterjee c4c156eb1d Minor fixes 2021-05-31 15:38:19 +05:30
Rajarshee Chatterjee f1d86b798e UI fixes 2021-05-31 15:38:05 +05:30
Rajarshee Chatterjee 4f1aa7c2cc History fixes 2021-05-31 15:37:07 +05:30
Rajarshee Chatterjee f8199b4e4e Reorganize library filter sheet 2021-05-31 15:32:42 +05:30
Rajarshee Chatterjee 82e7111a40 Add searchbar in history screen (#24) 2021-05-31 09:13:15 +05:30
Rajarshee Chatterjee a15369967f Add custom accent color 2021-05-30 18:19:59 +05:30
Rajarshee Chatterjee 5a936bea02 Rename extensions to sources 2021-05-30 18:19:39 +05:30
Rajarshee Chatterjee 13b49dfd21 Minor UI changes 2021-05-30 18:19:14 +05:30
Rajarshee Chatterjee 689185bab6 Display images if links are present in chapterText 2021-05-30 18:18:25 +05:30
Rajarshee Chatterjee 917dea2261 Fix white border between screens 2021-05-29 18:59:53 +05:30
Rajarshee Chatterjee 6afcdcf96e Reorganize novel details 2021-05-29 17:15:30 +05:30
Rajarshee Chatterjee b74066111f Parse images in chapter and reorganize reader sheet 2021-05-29 12:04:24 +05:30
Rajarshee Chatterjee b42fb6319b Add hide chapter titles 2021-05-28 17:11:23 +05:30
Rajarshee Chatterjee cb05231335 Group updates and history by date 2021-05-28 10:30:07 +05:30
Rajarshee Chatterjee 7819c7dcdb Add selecting chapters logic 2021-05-27 10:37:43 +05:30
Rajarshee Chatterjee 9a7e1f7e53 Refactor Chapter 2021-05-25 10:32:21 +05:30
Rajarshee Chatterjee 2c2e814a0d Fix reader sheet buttons 2021-05-20 10:17:33 +05:30
Rajarshee Chatterjee f4fc3d096f Refactor NovelHeader 2021-05-17 09:50:01 +05:30
Rajarshee Chatterjee d230b64846 Refactor settings and minor fixes 2021-05-16 18:44:37 +05:30
Rajarshee Chatterjee a280fc95e1 Reset progress if marked read 2021-05-16 16:36:42 +05:30
Rajarshee Chatterjee 1f87a50762 Fix scroll to saved progress 2021-05-16 08:58:39 +05:30
Rajarshee Chatterjee 99945aa0d8 Release v1.0.18 2021-05-14 23:29:08 +05:30
Rajarshee Chatterjee fda59f825f Fix library filters and minor fixes 2021-05-14 21:00:55 +05:30
Rajarshee Chatterjee 67096eb1ff Add library sort/filter sheet and notification icon 2021-05-14 18:30:15 +05:30
Rajarshee Chatterjee 3a156f32a0 Save scroll percentage and add new theme 2021-05-14 12:03:32 +05:30
Rajarshee Chatterjee 555552a64d Update app icon and splash screen 2021-05-14 09:14:31 +05:30
Rajarshee Chatterjee 53fc800b90 Minor fixes 2021-05-14 08:56:46 +05:30
Rajarshee Chatterjee d1e10433e2 Add bottomsheet for chapter actions 2021-05-13 08:21:46 +05:30
Rajarshee Chatterjee cc53e309ad Fix last read chapter getting reset on opening another novel (fixes #19) 2021-05-12 14:20:48 +05:30
Rajarshee Chatterjee 2170b77607 Add swipe to mark chapter read/ bookmark logic 2021-05-12 10:00:46 +05:30
Rajarshee Chatterjee 632c2e47c7 Add scroll percentage in chapter screen (fixes #17) 2021-05-11 08:43:42 +05:30
Rajarshee Chatterjee 5e2c4d4e27 Add searchbar in updates screen 2021-05-10 09:15:09 +05:30
Rajarshee Chatterjee a21800f208 Minor fixes 2021-05-09 10:10:09 +05:30
Rajarshee Chatterjee 5f943fcc92 Merge branch 'main' 2021-05-09 10:00:59 +05:30
Rajarshee Chatterjee 8f254b4ef5 Add Migration (#15)
* Refactor

* Add migration

* Finish migration

* Add progress bar
2021-05-09 09:57:21 +05:30
Rajarshee Chatterjee 773913048c Refactor 2021-05-07 09:36:07 +05:30
Rajarshee Chatterjee daf9444e87 Merge branch 'main' of https://github.com/rajarsheechatterjee/LNReader into main 2021-05-06 21:00:17 +05:30
Rajarshee Chatterjee e4b1b757e6 Fix navigating to novel from history screen 2021-05-06 20:59:22 +05:30
Rajarshee Chatterjee b01e63791e Release v1.0.17 2021-05-06 20:55:23 +05:30
Rajarshee Chatterjee ffe4950966 Update screenshots 2021-05-06 17:07:25 +05:30
Rajarshee Chatterjee caac2c3227 Refactor 2021-05-06 16:10:22 +05:30
Rajarshee Chatterjee 489d5ba714 Add global search (#12)
* Add global search

* Split GlobalSearch into components
2021-05-05 20:17:17 +05:30
Rajarshee Chatterjee 4251ec7f20 Pin sources and refactor 2021-05-05 14:19:08 +05:30
Rajarshee Chatterjee c1e0b01df8 Refactor and some fixes 2021-05-04 21:44:17 +05:30
Rajarshee Chatterjee 66dc1c4bdb fix continue reading button 2021-05-04 15:13:12 +05:30
Rajarshee Chatterjee 1ba88a16ff Save chapter sort and filter settings 2021-05-04 12:15:02 +05:30
Rajarshee Chatterjee 381c779c36 Release v1.0.16 2021-05-03 22:06:08 +05:30
Rajarshee Chatterjee 90df6b5aca Refactor 2021-05-03 20:49:06 +05:30
Rajarshee Chatterjee 87ff17d5e5 Use Expo StorageAccessFramework for backups 2021-05-01 17:51:50 +05:30
Rajarshee Chatterjee 913d319222 Check github for new release (closes #9) 2021-05-01 17:24:33 +05:30
Rajarshee Chatterjee cca143383e Upgrade Expo SDK to 41 (closes #7) 2021-05-01 08:38:06 +05:30
Rajarshee Chatterjee 4a874b30be Move useTheme hook to Hooks folder 2021-05-01 08:11:50 +05:30
Rajarshee Chatterjee e958b0fa09 Refactor 2021-05-01 07:57:16 +05:30
Rajarshee Chatterjee a484d550a8 Fix navigation to novel from history item 2021-04-30 23:35:48 +05:30
Rajarshee Chatterjee 4acafdb741 Add backup/restore (closes #6) 2021-04-30 23:18:09 +05:30
Rajarshee Chatterjee 1e6c4758ff Check github for new release on launch 2021-04-30 20:52:05 +05:30
Rajarshee Chatterjee 1ed0e8b4c4 Fix tracker crashes and update app version 2021-04-30 12:01:34 +05:30
Rajarshee Chatterjee 458cdb2c2a Add update chapter progress after reading 2021-04-30 11:16:14 +05:30
Rajarshee Chatterjee 0a476ba6b4 Update api url 2021-04-30 11:15:58 +05:30
Rajarshee Chatterjee 6e85909f6f Finish mal tracking and refactor 2021-04-29 20:36:32 +05:30
Rajarshee Chatterjee 5642122a5e Add updating mal list logic 2021-04-28 22:33:41 +05:30
Rajarshee Chatterjee 77481dfc06 Add MAL tracking logic 2021-04-27 20:33:22 +05:30
Rajarshee Chatterjee a870442022 Remove unused dependencies 2021-04-26 20:43:48 +05:30
Rajarshee Chatterjee a9a176297e Use single screen for all extensions 2021-04-26 18:34:12 +05:30
Rajarshee Chatterjee f4c0a647fb Add fonts in reader 2021-04-26 17:02:36 +05:30
Rajarshee Chatterjee 06e7e45df0 Fix theme name 2021-04-26 12:07:19 +05:30
Rajarshee Chatterjee c538bf4956 Add logo in more screen 2021-04-26 12:06:59 +05:30
Rajarshee Chatterjee e1679a41f1 Fix navigating to chapter from history screen 2021-04-26 12:05:35 +05:30
Rajarshee Chatterjee 3c2f4a59b7 Refactor settings screen 2021-04-26 11:34:04 +05:30
Rajarshee Chatterjee 8c13b1ff12 Add discord link 2021-04-25 19:59:48 +05:30
Rajarshee Chatterjee 45083e5fd1 Fix menu color in light theme 2021-04-25 19:48:49 +05:30
Rajarshee Chatterjee 3b37c1cb33 Disable OTA updates 2021-04-25 08:25:53 +05:30
Rajarshee Chatterjee 48187d7d54 Fix reader padding and update version 2021-04-24 21:55:19 +05:30
Rajarshee Chatterjee 3af0b1afbc Merge branch 'main' of https://github.com/rajarsheechatterjee/LNReader into main 2021-04-24 20:47:48 +05:30
Rajarshee Chatterjee b9695e7f56 Revert git rebase 2021-04-24 20:46:41 +05:30
Rajarshee Chatterjee 7c068aa7d5 Add download all chapters option (fixes #4) 2021-04-24 20:32:56 +05:30
Rajarshee Chatterjee 8b182f1e31 Add download all chapters option (fixes #4) 2021-04-24 20:31:38 +05:30
Rajarshee Chatterjee 5029513424 closes #4; Add download all chapters 2021-04-24 20:16:10 +05:30
Rajarshee Chatterjee f4c6a3b4a1 Fix download button animation 2021-04-24 18:03:59 +05:30
Rajarshee Chatterjee 27f5e20107 Minor fixes 2021-04-24 11:45:43 +05:30
Rajarshee Chatterjee c249b52304 Add textAlign and padding in reader settings 2021-04-24 11:45:32 +05:30
Rajarshee Chatterjee c179e0e5bd Fix sort and filter chapters 2021-04-24 10:25:39 +05:30
Rajarshee Chatterjee b78382cc03 Allow navigate to chapter from updates tab 2021-04-24 10:03:22 +05:30
Rajarshee Chatterjee 9df3694c04 Fix start reading button 2021-04-24 09:08:34 +05:30
Rajarshee Chatterjee dfd300c358 Fix library search 2021-04-07 09:21:22 +05:30
Rajarshee Chatterjee 76c108597d Add updates tab and updating library 2021-04-05 11:31:07 +05:30
Rajarshee Chatterjee 23ff02674e Fix crash on updating novel 2021-04-03 14:30:06 +05:30
Rajarshee Chatterjee 55e2371f5d Fix start reading button 2021-04-03 11:36:54 +05:30
162 changed files with 11866 additions and 5964 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+19 -34
View File
@@ -1,49 +1,34 @@
import React, { useState, useEffect } from "react";
import { StatusBar } from "expo-status-bar";
import { NavigationContainer } from "@react-navigation/native";
import * as Font from "expo-font";
import AppLoading from "expo-app-loading";
import React, { useEffect } from "react";
import { useFonts } from "expo-font";
import { Provider as PaperProvider } from "react-native-paper";
import Main from "./src/Navigators/Main";
import Router from "./src/navigation/Router";
// Redux
import { Provider } from "react-redux";
import { persistor, store } from "./src/redux/store";
import { PersistGate } from "redux-persist/lib/integration/react";
import { createDB } from "./src/database/DBHelper";
const getFonts = () =>
Font.loadAsync({
"pt-sans-bold": require("./assets/fonts/PTSansNarrow-Bold.ttf"),
});
import { createDB, deleteDb } from "./src/Database/DBHelper";
import { fonts } from "./src/Theme/fonts";
const App = () => {
const [fontsLoaded, setFontsLoaded] = useState(false);
const [loaded] = useFonts(fonts);
useEffect(() => {
createDB();
}, []);
if (fontsLoaded) {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<StatusBar />
<NavigationContainer>
<Router />
</NavigationContainer>
</PersistGate>
</Provider>
);
} else {
return (
<AppLoading
startAsync={getFonts}
onError={() => warn(error)}
onFinish={() => setFontsLoaded(true)}
/>
);
if (!loaded) {
return null;
}
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<PaperProvider>
<Main />
</PaperProvider>
</PersistGate>
</Provider>
);
};
export default App;
+14 -4
View File
@@ -2,8 +2,9 @@
"expo": {
"name": "LNReader",
"slug": "LNReader",
"version": "1.0.0",
"orientation": "portrait",
"scheme": "lnreader",
"version": "1.0.19",
"orientation": "default",
"icon": "./assets/icon.png",
"splash": {
"image": "./assets/splash.png",
@@ -11,7 +12,12 @@
"backgroundColor": "#000000"
},
"updates": {
"fallbackToCacheTimeout": 0
"fallbackToCacheTimeout": 0,
"enabled": false
},
"notification": {
"icon": "./assets/notification.png",
"color": "#00adb5"
},
"assetBundlePatterns": ["**/*"],
"ios": {
@@ -24,7 +30,11 @@
},
"package": "com.rajarsheechatterjee.LNReader"
},
"androidStatusBar": {
"barStyle": "light-content",
"backgroundColor": "#00000000",
"translucent": true
},
"androidNavigationBar": {
"barStyle": "light-content",
"backgroundColor": "#000000"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 266 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+2218 -2278
View File
File diff suppressed because it is too large Load Diff
+61 -54
View File
@@ -1,56 +1,63 @@
{
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"eject": "expo eject"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^1.13.2",
"@react-native-community/masked-view": "0.1.10",
"@react-native-community/netinfo": "^5.9.7",
"@react-native-community/slider": "^3.0.3",
"@react-navigation/material-bottom-tabs": "^5.3.10",
"@react-navigation/native": "^5.8.10",
"@react-navigation/stack": "^5.12.8",
"expo": "~40.0.0",
"expo-app-loading": "^1.0.1",
"expo-font": "^8.4.0",
"expo-linear-gradient": "^8.4.0",
"expo-sharing": "~8.5.0",
"expo-sqlite": "~8.5.0",
"expo-status-bar": "~1.0.3",
"expo-web-browser": "~8.6.0",
"moment": "^2.29.1",
"react": "16.13.1",
"react-color-extractor": "^1.1.2",
"react-dom": "16.13.1",
"react-native": "https://github.com/expo/react-native/archive/sdk-40.0.1.tar.gz",
"react-native-collapsible-header-views": "^1.1.2",
"react-native-gesture-handler": "^1.8.0",
"react-native-input-spinner": "^1.7.7",
"react-native-offline": "^5.7.0",
"react-native-paper": "^4.5.0",
"react-native-reanimated": "^1.13.2",
"react-native-safe-area-context": "3.1.9",
"react-native-screens": "~2.15.0",
"react-native-vector-icons": "^7.1.0",
"react-native-web": "~0.13.12",
"react-native-webview": "11.0.0",
"react-redux": "^7.2.2",
"redux": "^4.0.5",
"redux-devtools-extension": "^2.13.9",
"redux-persist": "^6.0.0",
"redux-thunk": "^2.3.0",
"reflect-metadata": "^0.1.13",
"rn-sliding-up-panel": "^2.4.4",
"typeorm": "^0.2.31"
},
"devDependencies": {
"@babel/core": "~7.9.0",
"@types/node": "^14.14.35"
},
"private": true
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"eject": "expo eject"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^1.13.0",
"@react-native-community/masked-view": "0.1.10",
"@react-native-community/netinfo": "6.0.0",
"@react-native-community/slider": "3.0.3",
"@react-navigation/material-bottom-tabs": "^5.3.10",
"@react-navigation/native": "^5.8.10",
"@react-navigation/stack": "^5.12.8",
"expo": "^41.0.1",
"expo-background-fetch": "~9.1.0",
"expo-document-picker": "^9.1.2",
"expo-file-system": "~11.0.2",
"expo-font": "~9.1.0",
"expo-linear-gradient": "~9.1.0",
"expo-linking": "~2.2.3",
"expo-notifications": "~0.11.6",
"expo-permissions": "^12.0.1",
"expo-sharing": "~9.1.2",
"expo-speech": "~9.1.0",
"expo-sqlite": "~9.1.0",
"expo-status-bar": "~1.0.4",
"expo-task-manager": "~9.1.0",
"expo-web-browser": "~9.1.0",
"moment": "^2.29.1",
"qs": "^6.10.1",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-native": "https://github.com/expo/react-native/archive/sdk-41.0.0.tar.gz",
"react-native-collapsible-header-views": "^1.1.2",
"react-native-easing-gradient": "^0.1.2",
"react-native-fit-image": "^1.5.5",
"react-native-gesture-handler": "~1.10.2",
"react-native-offline": "^5.7.0",
"react-native-paper": "^4.5.0",
"react-native-reanimated": "~2.1.0",
"react-native-safe-area-context": "3.2.0",
"react-native-screens": "~3.0.0",
"react-native-tab-view": "^3.0.1",
"react-native-vector-icons": "^7.1.0",
"react-native-web": "~0.13.12",
"react-native-webview": "11.2.3",
"react-redux": "^7.2.2",
"redux": "^4.0.5",
"redux-devtools-extension": "^2.13.9",
"redux-persist": "^6.0.0",
"redux-thunk": "^2.3.0",
"rn-sliding-up-panel": "^2.4.4",
"react-native-pager-view": "5.0.12"
},
"devDependencies": {
"@babel/core": "~7.9.0"
},
"private": true
}
@@ -10,11 +10,13 @@ import Constants from "expo-constants";
import { useNavigation } from "@react-navigation/native";
import { useSelector } from "react-redux";
export const Appbar = ({ title, onBackAction }) => {
const theme = useSelector((state) => state.themeReducer.theme);
export const Appbar = ({ title, onBackAction, style, children }) => {
const theme = useSelector((state) => state.settingsReducer.theme);
return (
<MaterialAppbar.Header style={{ backgroundColor: theme.colorPrimary }}>
<MaterialAppbar.Header
style={[{ backgroundColor: theme.colorPrimary }, style]}
>
{onBackAction && (
<MaterialAppbar.BackAction onPress={onBackAction} />
)}
@@ -22,6 +24,7 @@ export const Appbar = ({ title, onBackAction }) => {
title={title}
titleStyle={{ color: theme.textColorPrimary }}
/>
{children}
</MaterialAppbar.Header>
);
};
@@ -34,13 +37,13 @@ export const SearchAppbar = ({
getNovels,
setLoading,
screen,
onFilter,
openWebView,
}) => {
const searchRef = useRef(null);
const navigation = useNavigation();
const theme = useSelector((state) => state.themeReducer.theme);
const theme = useSelector((state) => state.settingsReducer.theme);
return (
<TouchableRipple
@@ -110,17 +113,15 @@ export const SearchAppbar = ({
}}
/>
)}
<IconButton
icon="filter-variant"
color={theme.textColorSecondary}
style={{ marginRight: 0 }}
size={23}
disabled={typeof onFilter === "function" ? false : true}
/**
* TODO
*/
onPress={() => onFilter?.()}
/>
{openWebView && (
<IconButton
icon="earth"
color={theme.textColorSecondary}
style={{ marginRight: 0 }}
size={23}
onPress={openWebView}
/>
)}
</View>
</TouchableRipple>
);
+17
View File
@@ -0,0 +1,17 @@
import React from "react";
import { View } from "react-native";
const BottomSheetHandle = ({ theme }) => (
<View
style={{
backgroundColor: theme.textColorHint,
height: 5,
width: 30,
borderRadius: 50,
top: 10,
alignSelf: "center",
}}
/>
);
export default BottomSheetHandle;
+21
View File
@@ -0,0 +1,21 @@
import React from "react";
import { View, Text, Image, StyleSheet } from "react-native";
const ScreenContainer = ({ children, theme }) => (
<View style={{ flex: 1, backgroundColor: theme.colorPrimaryDark }}>
{children}
</View>
);
const Row = ({ children, style }) => (
<View style={[styles.row, style]}>{children}</View>
);
export { ScreenContainer, Row };
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
},
});
@@ -2,17 +2,17 @@ import React from "react";
import { StyleSheet, Text, View } from "react-native";
import { useSelector } from "react-redux";
const EmptyView = ({ icon, description }) => {
const theme = useSelector((state) => state.themeReducer.theme);
const EmptyView = ({ icon, description, style, children, iconStyle }) => {
const theme = useSelector((state) => state.settingsReducer.theme);
return (
<View style={styles.emptyViewContainer}>
<Text
style={[
styles.emptyViewIcon,
{
color: theme.textColorSecondary,
},
{ color: theme.textColorSecondary },
style,
iconStyle,
]}
>
{icon}
@@ -20,13 +20,13 @@ const EmptyView = ({ icon, description }) => {
<Text
style={[
styles.emptyViewText,
{
color: theme.textColorSecondary,
},
{ color: theme.textColorSecondary },
style,
]}
>
{description}
</Text>
{children}
</View>
);
};
@@ -41,7 +41,6 @@ const styles = StyleSheet.create({
},
emptyViewIcon: {
fontSize: 45,
fontWeight: "bold",
},
emptyViewText: {
fontWeight: "bold",
+28
View File
@@ -0,0 +1,28 @@
import React from "react";
import { Text, View } from "react-native";
import { IconButton } from "react-native-paper";
import { Row } from "./Common";
import EmptyView from "./EmptyView";
const ErrorView = ({ theme, onRetry, error, openWebView }) => (
<View style={{ flex: 1, justifyContent: "center" }}>
<EmptyView
icon="(; ̄Д ̄)"
description={error}
style={{ color: theme.textColorSecondary }}
>
<IconButton
icon="reload"
size={25}
style={{ margin: 0, marginTop: 24 }}
color={theme.textColorSecondary}
onPress={onRetry}
/>
<Text style={{ color: theme.textColorPrimary }}>Retry</Text>
</EmptyView>
</View>
);
export default ErrorView;
+92
View File
@@ -0,0 +1,92 @@
import React from "react";
import { StyleSheet } from "react-native";
import { List, Divider as PaperDivider } from "react-native-paper";
import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons";
const ListSection = ({ children }) => (
<List.Section style={styles.listSection}>{children}</List.Section>
);
const ListSubHeader = ({ children, theme }) => (
<List.Subheader
style={[styles.listSubHeader, { color: theme.colorAccent }]}
>
{children}
</List.Subheader>
);
const ListItem = ({
title,
description,
icon,
onPress,
theme,
right,
iconColor,
titleStyle,
style,
}) => (
<List.Item
title={title}
style={style}
titleStyle={[{ color: theme.textColorPrimary }, titleStyle]}
description={description}
descriptionStyle={{ color: theme.textColorSecondary }}
descriptionNumberOfLines={1}
left={() =>
icon && (
<List.Icon
color={theme.colorAccent}
icon={icon}
style={{ marginVertical: 0 }}
/>
)
}
right={() =>
right && (
<MaterialCommunityIcons
name={right}
color={iconColor || "#47a84a"}
size={23}
style={{ marginRight: 16, textAlignVertical: "center" }}
/>
)
}
onPress={onPress}
rippleColor={theme.rippleColor}
/>
);
const Divider = ({ theme }) => (
<PaperDivider style={{ backgroundColor: theme.dividerColor, height: 1 }} />
);
const InfoItem = ({ title, icon, theme }) => (
<List.Item
title={title}
titleStyle={{ color: theme.textColorSecondary, fontSize: 14 }}
titleNumberOfLines={5}
left={() =>
icon && (
<List.Icon
color={theme.textColorSecondary}
icon={icon}
style={{ marginVertical: 0 }}
/>
)
}
/>
);
export { ListSection, ListSubHeader, ListItem, Divider, InfoItem };
const styles = StyleSheet.create({
listSection: {
flex: 1,
marginVertical: 0,
},
listSubHeader: {
paddingBottom: 5,
},
});
+64
View File
@@ -0,0 +1,64 @@
import React from "react";
import { StyleSheet, View, Text, Image } from "react-native";
import { TouchableRipple } from "react-native-paper";
const ListView = ({ item, downloadBadge, unreadBadge, theme, onPress }) => {
return (
<TouchableRipple
borderless
centered
rippleColor={theme.rippleColor}
style={styles.listView}
onPress={onPress}
>
<>
<Image
source={{ uri: item.novelCover }}
style={styles.extensionIcon}
/>
<Text
style={[
{ color: theme.textColorPrimary },
styles.novelName,
]}
numberOfLines={1}
>
{item.novelName}
</Text>
<View style={styles.badgeContainer}>
{downloadBadge}
{unreadBadge}
</View>
</>
</TouchableRipple>
);
};
export default ListView;
const styles = StyleSheet.create({
listView: {
flex: 1,
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 12,
},
extensionIcon: {
width: 40,
height: 40,
borderRadius: 4,
},
novelName: {
flex: 1,
marginLeft: 16,
fontSize: 15,
paddingRight: 8,
flexWrap: "wrap",
},
badgeContainer: {
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "flex-end",
},
});
+80
View File
@@ -0,0 +1,80 @@
import React, { useState } from "react";
import { StyleSheet, View, Text } from "react-native";
import { Portal, Modal, Button } from "react-native-paper";
import * as Linking from "expo-linking";
import { useTheme } from "../Hooks/reduxHooks";
const NewUpdateDialog = ({ newVersion }) => {
const [newUpdateDialog, showNewUpdateDialog] = useState(true);
const theme = useTheme();
return (
<Portal>
<Modal
visible={newUpdateDialog}
onDismiss={() => showNewUpdateDialog(false)}
contentContainerStyle={[
styles.containerStyle,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<Text
style={[
styles.modalHeader,
{ color: theme.textColorPrimary },
]}
>
{`New update available ${newVersion.tag_name}`}
</Text>
<Text style={{ color: theme.textColorSecondary, fontSize: 16 }}>
{newVersion.body}
</Text>
<View
style={{
flexDirection: "row",
marginTop: 30,
justifyContent: "flex-end",
}}
>
<Button
labelStyle={{
color: theme.colorAccent,
letterSpacing: 0,
textTransform: "none",
}}
theme={{ colors: { primary: theme.colorAccent } }}
onPress={() => showNewUpdateDialog(false)}
>
Cancel
</Button>
<Button
labelStyle={{
color: theme.colorAccent,
letterSpacing: 0,
textTransform: "none",
}}
theme={{ colors: { primary: theme.colorAccent } }}
onPress={() => Linking.openURL(newVersion.downloadUrl)}
>
Install
</Button>
</View>
</Modal>
</Portal>
);
};
export default NewUpdateDialog;
const styles = StyleSheet.create({
containerStyle: {
padding: 20,
margin: 20,
borderRadius: 6,
},
modalHeader: {
fontSize: 20,
marginBottom: 10,
},
});
+188
View File
@@ -0,0 +1,188 @@
import React from "react";
import { StyleSheet, View, Text, ImageBackground, Image } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { TouchableRipple } from "react-native-paper";
import { useSelector } from "react-redux";
import { useSettings, useTheme } from "../Hooks/reduxHooks";
import ListView from "./ListView";
const NovelCover = ({ item, onPress, libraryStatus }) => {
const theme = useTheme();
const { displayMode, novelsPerRow, showDownloadBadges, showUnreadBadges } =
useSettings();
const height = {
1: 550,
2: 280,
3: 180,
4: 140,
5: 100,
};
const comfortableTitle = () =>
displayMode === 1 && (
<Text
numberOfLines={2}
style={[
styles.title,
{
color: theme.textColorPrimary,
padding: 4,
},
]}
>
{item.novelName}
</Text>
);
const compactTitle = () =>
displayMode === 0 && (
<View style={styles.titleContainer}>
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.6)"]}
style={styles.linearGradient}
>
<Text
numberOfLines={2}
style={[styles.title, { color: "rgba(255,255,255,1)" }]}
>
{item.novelName}
</Text>
</LinearGradient>
</View>
);
const unreadBadge = () =>
showUnreadBadges &&
item.chaptersUnread && (
<Text
style={[
styles.unreadBadge,
!item.chaptersDownloaded && {
borderTopLeftRadius: 4,
borderBottomLeftRadius: 4,
},
!showDownloadBadges && {
borderRadius: 4,
},
]}
>
{item.chaptersUnread}
</Text>
);
const downloadBadge = () =>
showDownloadBadges &&
item.chaptersDownloaded && (
<Text
style={[
styles.downloadBadge,
!item.chaptersUnread && {
borderTopRightRadius: 4,
borderBottomRightRadius: 4,
},
!showUnreadBadges && {
borderRadius: 4,
},
]}
>
{item.chaptersDownloaded}
</Text>
);
return displayMode !== 2 ? (
<View style={{ flex: 1 / novelsPerRow }}>
<TouchableRipple
borderless
centered
rippleColor={theme.rippleColor}
style={styles.opac}
onPress={onPress}
>
<>
<ImageBackground
source={{ uri: item.novelCover }}
style={{ height: height[novelsPerRow] }}
imageStyle={[
{ borderRadius: 4 },
libraryStatus && { opacity: 0.5 },
]}
progressiveRenderingEnabled={true}
>
<View style={{ flexDirection: "row", margin: 4 }}>
{downloadBadge()}
{unreadBadge()}
</View>
{compactTitle()}
</ImageBackground>
{comfortableTitle()}
</>
</TouchableRipple>
</View>
) : (
<ListView
item={item}
downloadBadge={downloadBadge()}
unreadBadge={unreadBadge()}
theme={theme}
onPress={onPress}
/>
);
};
export default NovelCover;
const styles = StyleSheet.create({
titleContainer: {
flex: 1,
justifyContent: "flex-end",
borderRadius: 4,
},
title: {
fontFamily: "pt-sans-bold",
fontSize: 14,
padding: 8,
},
linearGradient: {
bottom: -1,
borderRadius: 4,
},
opac: {
paddingHorizontal: 4.5,
paddingVertical: 4,
borderRadius: 4,
},
extensionIcon: {
width: 42,
height: 42,
borderRadius: 4,
},
listView: {
flex: 1,
paddingVertical: 8,
paddingHorizontal: 12,
flexDirection: "row",
alignItems: "center",
borderRadius: 4,
},
downloadBadge: {
backgroundColor: "#47a84a",
color: "#FFFFFF",
borderTopLeftRadius: 4,
borderBottomLeftRadius: 4,
paddingTop: 2,
paddingHorizontal: 4,
fontSize: 12,
},
unreadBadge: {
backgroundColor: "#2979FF",
color: "#FFFFFF",
borderTopRightRadius: 4,
borderBottomRightRadius: 4,
paddingTop: 2,
paddingHorizontal: 4,
fontSize: 12,
},
});
+38
View File
@@ -0,0 +1,38 @@
import React from "react";
import { StyleSheet, FlatList } from "react-native";
import { useSettings } from "../Hooks/reduxHooks";
const NovelList = ({
data,
renderItem,
refreshControl,
ListEmptyComponent,
}) => {
const { displayMode, novelsPerRow } = useSettings();
const getNovelsPerRow = () => (displayMode === 2 ? 1 : novelsPerRow);
return (
<FlatList
contentContainerStyle={styles.flatListCont}
numColumns={getNovelsPerRow()}
key={getNovelsPerRow()}
data={data}
keyExtractor={(item) => item.novelUrl}
renderItem={renderItem}
refreshControl={refreshControl}
ListEmptyComponent={ListEmptyComponent}
/>
);
};
export default NovelList;
const styles = StyleSheet.create({
flatListCont: {
flexGrow: 1,
paddingVertical: 8,
paddingHorizontal: 4,
},
});
+40
View File
@@ -0,0 +1,40 @@
import React from "react";
import { StyleSheet, View, Text } from "react-native";
import { RadioButton as MaterialRadioButton } from "react-native-paper";
export const RadioButtonGroup = ({ children, value, onValueChange }) => (
<MaterialRadioButton.Group onValueChange={onValueChange} value={value}>
{children}
</MaterialRadioButton.Group>
);
export const RadioButton = ({ value, label, theme, labelStyle }) => (
<View style={styles.radioButtonContainer}>
<MaterialRadioButton
value={value}
color={theme.colorAccent}
uncheckedColor={theme.textColorSecondary}
/>
<Text
style={[
styles.radioButtonLabel,
{ color: theme.textColorPrimary },
labelStyle,
]}
>
{label}
</Text>
</View>
);
const styles = StyleSheet.create({
radioButtonContainer: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
},
radioButtonLabel: {
marginLeft: 16,
fontSize: 16,
},
});
+142
View File
@@ -0,0 +1,142 @@
import React, { useRef } from "react";
import { View, TextInput, StyleSheet } from "react-native";
import { TouchableRipple, IconButton } from "react-native-paper";
import Constants from "expo-constants";
import { useSettings } from "../Hooks/reduxHooks";
import { useDispatch } from "react-redux";
import { setAppSettings } from "../redux/settings/settings.actions";
export const Searchbar = ({
theme,
placeholder,
searchText,
left,
onPressLeft,
right,
displayMenu,
onPressRight,
clearSearchbar,
onChangeText,
onSubmitEditing,
migrate,
onPressMigrate,
filter,
}) => {
const searchRef = useRef(null);
const dispatch = useDispatch();
const { displayMode } = useSettings();
const displayMenuIcon = () => {
const icons = {
0: "view-module",
1: "view-list",
2: "view-module",
};
return icons[displayMode];
};
const getDisplayMode = () =>
displayMode === 0 ? 1 : displayMode === 1 ? 2 : displayMode === 2 && 0;
return (
<TouchableRipple
borderless
onPress={() => searchRef.current.focus()}
style={[
styles.searchAppbarContainer,
{ backgroundColor: theme.searchBarColor },
]}
rippleColor={theme.rippleColor}
>
<View style={styles.container}>
<View style={{ flex: 1, flexDirection: "row" }}>
{left && (
<IconButton
icon={left}
color={theme.textColorSecondary}
style={{ marginLeft: 0 }}
size={23}
onPress={onPressLeft}
/>
)}
<TextInput
ref={searchRef}
style={{
fontSize: 16,
color: theme.textColorSecondary,
}}
placeholder={placeholder}
placeholderTextColor={theme.textColorSecondary}
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
defaultValue={searchText}
/>
</View>
{searchText !== "" && (
<IconButton
icon="close"
color={theme.textColorSecondary}
style={{ marginRight: 0 }}
size={23}
onPress={clearSearchbar}
/>
)}
{displayMenu && (
<IconButton
icon={displayMenuIcon()}
color={theme.textColorSecondary}
style={{ marginRight: 0 }}
size={23}
onPress={() =>
dispatch(
setAppSettings("displayMode", getDisplayMode())
)
}
/>
)}
{right && (
<IconButton
icon={right}
color={
!filter
? theme.textColorSecondary
: theme.filterColor
}
style={{ marginRight: 0 }}
size={23}
onPress={onPressRight}
/>
)}
{migrate && (
<IconButton
icon={migrate}
color={theme.textColorSecondary}
style={{ marginRight: 0 }}
size={23}
onPress={onPressMigrate}
/>
)}
</View>
</TouchableRipple>
);
};
const styles = StyleSheet.create({
searchAppbarContainer: {
marginTop: Constants.statusBarHeight + 4,
height: 48,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
marginBottom: 12,
borderRadius: 8,
marginHorizontal: 12,
elevation: 2,
},
container: {
flex: 1,
flexDirection: "row",
justifyContent: "space-between",
},
});
@@ -14,6 +14,10 @@ import {
createChapterIdIndexQuery,
} from "./tables/HistoryTable";
import { createDownloadTableQuery } from "./tables/DownloadTable";
import {
createUpdatesTableQuery,
updatesSeedDataQuery,
} from "./tables/UpdateTable";
/**
* Database Version = 2
@@ -29,6 +33,7 @@ export const createDB = () => {
tx.executeSql(createChapterTableQuery);
tx.executeSql(createHistoryTableQuery);
tx.executeSql(createDownloadTableQuery);
tx.executeSql(createUpdatesTableQuery);
//Db indexes
tx.executeSql(createUrlIndexQuery);
@@ -45,5 +50,6 @@ export const deleteDb = () => {
tx.executeSql("DROP TABLE chapters");
tx.executeSql("DROP TABLE history");
tx.executeSql("DROP TABLE downloads");
tx.executeSql("DROP TABLE updates");
});
};
@@ -5,24 +5,46 @@ const insertChaptersQuery = `INSERT INTO chapters (chapterUrl, chapterName, rele
export const insertChapters = async (novelId, chapters) => {
db.transaction((tx) => {
chapters.map((chapter) =>
tx.executeSql(insertChaptersQuery, [
chapter.chapterUrl,
chapter.chapterName,
chapter.releaseDate,
novelId,
])
chapters.map(
(chapter) =>
tx.executeSql(
insertChaptersQuery,
[
chapter.chapterUrl,
chapter.chapterName,
chapter.releaseDate,
novelId,
],
(txObj, res) => {},
(txObj, error) =>
console.log(
"Error ",
error,
" Chapter URL: ",
chapter.chapterUrl,
" Novel ID ",
novelId
)
),
(txObj, res) => {
console.log("Success");
},
(txObj, error) => console.log("Error ", error)
);
});
};
const getChaptersQuery = `SELECT * FROM chapters WHERE novelId = ? ORDER BY chapterId ASC`;
const getChaptersQuery = (sort, filter) =>
`SELECT * FROM chapters WHERE novelId = ? ${filter || ""} ${
sort || "ORDER BY chapterId ASC"
}`;
export const getChapters = (novelId) => {
export const getChapters = (novelId, sort, filter) => {
// console.log(getChaptersQuery(sort, filter));
return new Promise((resolve, reject) =>
db.transaction((tx) => {
tx.executeSql(
getChaptersQuery,
getChaptersQuery(sort, filter),
[novelId],
(txObj, { rows: { _array } }) => {
resolve(_array);
@@ -35,7 +57,7 @@ export const getChapters = (novelId) => {
const getChapterQuery = `SELECT * FROM downloads WHERE downloadChapterId = ?`;
export const getChapter = async (chapterId) => {
export const getChapterFromDB = async (chapterId) => {
return new Promise((resolve, reject) =>
db.transaction((tx) => {
tx.executeSql(
@@ -50,6 +72,36 @@ export const getChapter = async (chapterId) => {
);
};
export const getNextChapterFromDB = async (novelId, chapterId) => {
return new Promise((resolve, reject) =>
db.transaction((tx) => {
tx.executeSql(
`SELECT * FROM chapters WHERE novelId = ? AND chapterId > ?`,
[novelId, chapterId],
(tx, results) => {
resolve(results.rows.item(0));
},
(txObj, error) => console.log("Error ", error)
);
})
);
};
export const getPrevChapterFromDB = async (novelId, chapterId) => {
return new Promise((resolve, reject) =>
db.transaction((tx) => {
tx.executeSql(
`SELECT * FROM chapters WHERE novelId = ? AND chapterId < ?`,
[novelId, chapterId],
(tx, results) => {
resolve(results.rows.item(results.rows.length - 1));
},
(txObj, error) => console.log("Error ", error)
);
})
);
};
const markChapterReadQuery = `UPDATE chapters SET \`read\` = 1 WHERE chapterId = ?`;
export const markChapterRead = async (chapterId) => {
@@ -63,6 +115,19 @@ export const markChapterRead = async (chapterId) => {
});
};
const markChapterUnreadQuery = `UPDATE chapters SET \`read\` = 0 WHERE chapterId = ?`;
export const markChapterUnread = async (chapterId) => {
db.transaction((tx) => {
tx.executeSql(
markChapterUnreadQuery,
[chapterId],
(tx, res) => {},
(tx, error) => console.log(error)
);
});
};
const isChapterDownloadedQuery = `SELECT * FROM downloads WHERE downloadChapterId=?`;
export const isChapterDownloaded = async (chapterId) => {
@@ -87,12 +152,12 @@ export const isChapterDownloaded = async (chapterId) => {
const downloadChapterQuery = `INSERT INTO downloads (downloadChapterId, chapterName, chapterText, prevChapter, nextChapter) VALUES (?, ?, ?, ?, ?)`;
export const downloadChapter = async (
extensionId,
sourceId,
novelUrl,
chapterUrl,
chapterId
) => {
const downloadUrl = `https://lnreader-extensions.herokuapp.com/api/${extensionId}/novel/${novelUrl}${chapterUrl}`;
const downloadUrl = `https://lnreader-extensions.vercel.app/api/${sourceId}/novel/${novelUrl}${chapterUrl}`;
const response = await fetch(downloadUrl);
const chapter = await response.json();
@@ -126,7 +191,7 @@ export const deleteChapter = async (chapterId) => {
tx.executeSql(
updateIsDownloadedQuery,
[chapterId],
(tx, res) => console.log(`Chaptermarked not downapdeod deleted`),
(tx, res) => {},
(txObj, error) => console.log("Error ", error)
);
tx.executeSql(
@@ -158,3 +223,42 @@ export const getLastReadChapter = async (novelId) => {
});
});
};
const bookmarkChapterQuery = `UPDATE chapters SET bookmark = ? WHERE chapterId = ?`;
export const bookmarkChapter = async (bookmark, chapterId) => {
db.transaction((tx) => {
tx.executeSql(
bookmarkChapterQuery,
[!bookmark, chapterId],
(tx, res) => {},
(txObj, error) => console.log("Error ", error)
);
});
};
const markPreviuschaptersReadQuery = `UPDATE chapters SET \`read\` = 1 WHERE chapterId < ? AND novelId = ?`;
export const markPreviuschaptersRead = async (chapterId, novelId) => {
db.transaction((tx) => {
tx.executeSql(
markPreviuschaptersReadQuery,
[chapterId, novelId],
(tx, res) => {},
(tx, error) => console.log(error)
);
});
};
const markPreviousChaptersUnreadQuery = `UPDATE chapters SET \`read\` = 0 WHERE chapterId < ? AND novelId = ?`;
export const markPreviousChaptersUnread = async (chapterId, novelId) => {
db.transaction((tx) => {
tx.executeSql(
markPreviousChaptersUnreadQuery,
[chapterId, novelId],
(tx, res) => {},
(tx, error) => console.log(error)
);
});
};
@@ -8,7 +8,10 @@ const getHistoryQuery = `
ON history.historyChapterId = chapters.chapterId
JOIN novels
ON history.historyNovelId = novels.novelId
ORDER BY history.historyTimeRead DESC`;
GROUP BY novels.novelId
HAVING history.historyTimeRead = MAX(history.historyTimeRead)
ORDER BY history.historyTimeRead DESC
`;
export const getHistory = async () => {
return new Promise((resolve, reject) => {
@@ -30,7 +33,7 @@ export const getHistory = async () => {
export const insertHistory = async (novelId, chapterId) => {
db.transaction((tx) => {
tx.executeSql(
"INSERT OR REPLACE INTO history (historyNovelId, historyChapterId) VALUES (?, ?)",
"INSERT OR REPLACE INTO history (historyNovelId, historyChapterId, historyTimeRead) VALUES (?, ?, (datetime('now','localtime')))",
[novelId, chapterId],
(txObj, res) => {},
(txObj, error) => console.log("Error ", error)
@@ -41,15 +44,18 @@ export const insertHistory = async (novelId, chapterId) => {
});
};
export const deleteHistory = async (novelId) => {
export const deleteHistory = async (historyId) => {
db.transaction((tx) => {
tx.executeSql("DELETE FROM history WHERE historyNovelId = ?", [
novelId,
]);
tx.executeSql(
"DELETE FROM history WHERE historyId = ?",
[historyId],
(txObj, res) => {},
(txObj, error) => console.log("Error ", error)
);
});
};
export const deleteAllHistory = () => {
export const deleteAllHistory = async () => {
db.transaction((tx) => {
tx.executeSql("DELETE FROM history; VACCUM;");
});
@@ -1,7 +1,7 @@
import * as SQLite from "expo-sqlite";
const db = SQLite.openDatabase("lnreader.db");
const getLibraryQuery = `
const getLibraryQuery = (sort, filter) => `
SELECT novels.*, C.chaptersUnread, D.chaptersDownloaded
FROM novels
LEFT JOIN (
@@ -18,15 +18,15 @@ const getLibraryQuery = `
GROUP BY chapters.novelId
) AS D
ON novels.novelId = D.novelId
WHERE novels.followed = 1
GROUP BY novels.novelId
WHERE novels.followed = 1 ${filter ? "AND " + filter : ""}
${sort ? "ORDER BY " + sort : ""}
`;
export const getLibrary = () => {
export const getLibrary = (sort, filter) => {
return new Promise((resolve, reject) =>
db.transaction((tx) => {
tx.executeSql(
getLibraryQuery,
getLibraryQuery(sort, filter),
null,
(txObj, { rows: { _array } }) => {
// console.log(_array);
@@ -38,13 +38,34 @@ export const getLibrary = () => {
);
};
// const searchLibraryQuery = ``;
const searchLibraryQuery = (searchText, sort, filter) =>
`
SELECT novels.*, C.chaptersUnread, D.chaptersDownloaded
FROM novels
LEFT JOIN (
SELECT chapters.novelId, COUNT(*) AS chaptersUnread
FROM chapters
WHERE chapters.read = 0
GROUP BY chapters.novelId
) AS C
ON novels.novelId = C.novelId
LEFT JOIN (
SELECT chapters.novelId, COUNT(*) AS chaptersDownloaded
FROM chapters
WHERE chapters.downloaded = 1
GROUP BY chapters.novelId
) AS D
ON novels.novelId = D.novelId
WHERE novels.followed = 1 AND novelName LIKE '%${searchText}%' OR source LIKE '%${searchText}%' ${
filter ? "AND " + filter : ""
}
${sort ? "ORDER BY " + sort : ""} `;
export const searchLibrary = (searchText) => {
export const searchLibrary = (searchText, sort, filter) => {
return new Promise((resolve, reject) => {
db.transaction((tx) => {
tx.executeSql(
`SELECT * FROM novels WHERE followed = 1 AND novelName LIKE '%${searchText}%'`,
searchLibraryQuery(searchText, sort, filter),
null,
(txObj, { rows: { _array } }) => resolve(_array),
(txObj, error) => console.log("Error ", error)
+159
View File
@@ -0,0 +1,159 @@
import * as SQLite from "expo-sqlite";
const db = SQLite.openDatabase("lnreader.db");
import { fetchChapters, fetchNovel } from "../../Services/Source/source";
import { insertChapters } from "./ChapterQueries";
const insertNovelQuery = `INSERT INTO novels (novelUrl, sourceUrl, sourceId, source, novelName, novelCover, novelSummary, author, artist, status, genre) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
export const insertNovel = async (novel) => {
return new Promise((resolve, reject) =>
db.transaction((tx) =>
tx.executeSql(
insertNovelQuery,
[
novel.novelUrl,
novel.sourceUrl,
novel.sourceId,
novel.source,
novel.novelName,
novel.novelCover,
novel.novelSummary,
novel.author,
novel.artist,
novel.status,
novel.genre,
],
(txObj, { insertId }) => resolve(insertId),
(txObj, error) => console.log("Error ", error)
)
)
);
};
export const followNovel = async (followed, novelId) => {
db.transaction((tx) => {
tx.executeSql(
"UPDATE novels SET followed = ? WHERE novelId = ?",
[!followed, novelId],
(tx, res) => {},
(txObj, error) => console.log("Error ", error)
);
});
};
const checkNovelInCacheQuery = `SELECT * FROM novels WHERE novelUrl=? LIMIT 1`;
export const checkNovelInCache = (novelUrl) => {
return new Promise((resolve, reject) =>
db.transaction((tx) => {
tx.executeSql(
checkNovelInCacheQuery,
[novelUrl],
(txObj, res) => {
if (res.rows.length !== 0) {
resolve(true);
} else {
resolve(false);
}
},
(txObj, error) => console.log("Error ", error)
);
})
);
};
export const getNovel = async (sourceId, novelUrl) => {
return new Promise((resolve, reject) =>
db.transaction((tx) => {
tx.executeSql(
`SELECT * FROM novels WHERE novelUrl = ? AND sourceId = ?`,
[novelUrl, sourceId],
(txObj, { rows }) => resolve(rows.item(0)),
(txObj, error) => console.log("Error ", error)
);
})
);
};
export const deleteNovelCache = () => {
db.transaction((tx) => {
tx.executeSql(
"DELETE FROM novels WHERE followed = 0",
null,
(txObj, res) => {
console.log("Cleared Novel Cache");
},
(txObj, error) => console.log("Error ", error)
);
});
};
const restoreFromBackupQuery = `INSERT INTO novels (novelUrl, sourceUrl, sourceId, source, novelName, novelCover, novelSummary, author, artist, status, genre, followed, unread) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
export const restoreLibrary = async (novel) => {
return new Promise((resolve, reject) =>
db.transaction((tx) =>
tx.executeSql(
restoreFromBackupQuery,
[
novel.novelUrl,
novel.sourceUrl,
novel.sourceId,
novel.source,
novel.novelName,
novel.novelCover,
novel.novelSummary,
novel.author,
novel.artist,
novel.status,
novel.genre,
novel.followed,
novel.unread,
],
async (txObj, { insertId }) => {
const chapters = await fetchChapters(
novel.sourceId,
novel.novelUrl
);
await insertChapters(insertId, chapters);
},
(txObj, error) => console.log("Error ", error)
)
)
);
};
const migrateNovelQuery = `INSERT INTO novels (novelUrl, sourceUrl, sourceId, source, novelName, novelCover, novelSummary, author, artist, status, genre, followed) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
export const migrateNovel = async (sourceId, novelUrl) => {
const novel = await fetchNovel(sourceId, novelUrl);
db.transaction((tx) =>
tx.executeSql(
migrateNovelQuery,
[
novel.novelUrl,
novel.sourceUrl,
novel.sourceId,
novel.source,
novel.novelName,
novel.novelCover,
novel.novelSummary,
novel.author,
novel.artist,
novel.status,
novel.genre,
1,
],
async (txObj, { insertId }) => {
const chapters = await fetchChapters(
novel.sourceId,
novel.novelUrl
);
await insertChapters(insertId, chapters);
},
(txObj, error) => console.log("Error ", error)
)
);
};
+38
View File
@@ -0,0 +1,38 @@
import * as SQLite from "expo-sqlite";
const db = SQLite.openDatabase("lnreader.db");
const getUpdatesQuery = `
SELECT chapters.chapterId, chapters.read, chapters.downloaded, chapters.chapterUrl, chapters.chapterName, novels.novelUrl, novels.novelId, novels.novelCover, novels.novelName, novels.sourceId, updates.updateTime, updates.updateId
FROM updates
JOIN chapters
ON updates.chapterId = chapters.chapterId
JOIN novels
ON updates.novelId = novels.novelId
ORDER BY updates.updateTime DESC
`;
export const getUpdates = async () => {
return new Promise((resolve, reject) => {
db.transaction((tx) => {
tx.executeSql(
getUpdatesQuery,
null,
(txObj, { rows: { _array } }) => {
resolve(_array);
},
(txObj, error) => console.log("Error ", error)
);
});
});
};
export const deleteNovelUpdates = (novelId) => {
db.transaction((tx) => {
tx.executeSql(
"DELETE FROM updates WHERE novelId = ?",
[novelId],
(txObj, res) => {},
(txObj, error) => console.log("Error ", error)
);
});
};
@@ -6,6 +6,6 @@ export const createDownloadTableQuery = `
chapterText TEXT,
nextChapter TEXT,
prevChapter TEXT,
FOREIGN KEY (downloadChapterId) REFERENCES chapters(chapter_id)
FOREIGN KEY (downloadChapterId) REFERENCES chapters(chapterId)
ON DELETE CASCADE
)`;
@@ -1,8 +1,8 @@
export const createHistoryTableQuery = `
CREATE TABLE IF NOT EXISTS history(
historyId INTEGER PRIMARY KEY AUTOINCREMENT,
historyChapterId INTEGER NOT NULL,
historyNovelId INTEGER NOT NULL UNIQUE,
historyChapterId INTEGER NOT NULL UNIQUE,
historyNovelId INTEGER NOT NULL,
historyTimeRead DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (historyChapterId) REFERENCES chapters(chapterId)
ON DELETE CASCADE
+11
View File
@@ -0,0 +1,11 @@
export const createUpdatesTableQuery = `
CREATE TABLE IF NOT EXISTS updates(
updateId INTEGER PRIMARY KEY AUTOINCREMENT,
chapterId INTEGER NOT NULL,
novelId INTEGER NOT NULL,
updateTime DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(novelId, chapterId),
FOREIGN KEY (chapterId) REFERENCES chapters(chapterId),
FOREIGN KEY (novelId) REFERENCES novels(novelId)
ON DELETE CASCADE
)`;
+46
View File
@@ -0,0 +1,46 @@
import { useState, useEffect } from "react";
export const githubUpdateChecker = () => {
const latestReleaseUrl =
"https://api.github.com/repos/rajarsheechatterjee/lnreader/releases/latest";
const [checking, setChecking] = useState(true);
const [latestRelease, setLatestRelease] = useState();
const checkForRelease = async () => {
let res = await fetch(latestReleaseUrl);
res = await res.json();
const release = {
tag_name: res.tag_name,
body: res.body,
downloadUrl: res.assets[0].browser_download_url,
};
setLatestRelease(release);
setChecking(false);
};
const isNewVersion = (versionTag) => {
let currentVersion = "v1.0.19";
const regex = /[^\\d.]/;
let newVersion = versionTag.replace(regex, "");
currentVersion = currentVersion.replace(regex, "");
return newVersion !== currentVersion;
};
useEffect(() => {
checkForRelease();
}, []);
if (!checking) {
const data = {
latestRelease,
isNewVersion: isNewVersion(latestRelease.tag_name),
};
return data;
}
};
+131
View File
@@ -0,0 +1,131 @@
import { shallowEqual, useSelector } from "react-redux";
import { showChapterTitlesAction } from "../redux/novel/novel.actions";
const useTheme = () => {
const theme = useSelector((state) => state.settingsReducer.theme);
return theme;
};
const useReaderSettings = () => {
const readerSettings = useSelector((state) => state.settingsReducer.reader);
return readerSettings;
};
const useSettings = () => {
const settings = useSelector((state) => state.settingsReducer);
return settings;
};
const useLibrary = () => {
const library = useSelector((state) => state.libraryReducer.novels);
return library;
};
const useNovel = () => {
const novel = useSelector((state) => state.novelReducer);
return novel;
};
const useChapter = () => {
const chapter = useSelector((state) => state.chapterReducer);
return chapter;
};
const findNovel = (novelId) => {
let novel = useSelector((state) => state.preferenceReducer.novelSettings);
novel = novel[novelId];
return novel;
};
const usePreferences = (novelId) => {
let sort, filter, position, showChapterTitles;
const novel = findNovel(novelId);
if (novel) {
sort = novel.sort;
filter = novel.filter;
position = novel.position;
showChapterTitles = novel.showChapterTitles;
}
return { sort, filter, position, showChapterTitles };
};
const useSavedSettings = () => {
const settings = useSelector(
(state) => state.preferenceReducer.novelSettings
);
return settings;
};
const useContinueReading = (chapters, novelId) => {
let lastReadChapter, chapterId, novel, position;
novel = findNovel(novelId);
if (novel) {
chapterId = novel.lastRead;
position = novel.position;
}
if (chapterId) {
lastReadChapter = chapters.find(
(obj) => obj.chapterId === chapterId && obj.read === 0
);
}
if (!lastReadChapter) {
lastReadChapter = chapters.find((obj) => obj.read === 0);
}
return { lastReadChapter, position };
};
const usePosition = (novelId, chapterId) => {
let novel, position;
novel = findNovel(novelId);
if (novel) {
if (novel.position) {
position = novel.position[chapterId] || null;
}
}
return position;
};
const useTrackingStatus = () => {
const tracker = useSelector((state) => state.trackerReducer);
return tracker;
};
const useLibraryFilters = () => {
const filters = useSelector((state) => state.libraryReducer.filters);
return filters;
};
export {
useTheme,
useReaderSettings,
useLibrary,
useSettings,
usePreferences,
useContinueReading,
useTrackingStatus,
useNovel,
useChapter,
useSavedSettings,
useLibraryFilters,
usePosition,
};
+7
View File
@@ -0,0 +1,7 @@
import { setStatusBarStyle as setExpoStatusBarStyle } from "expo-status-bar";
import { useTheme } from "../Hooks/reduxHooks";
export const setStatusBarStyle = () => {
const theme = useTheme();
setExpoStatusBarStyle(theme.id === 1 ? "dark" : "light");
};
+5
View File
@@ -0,0 +1,5 @@
import { ToastAndroid } from "react-native";
export const showToast = (message) => {
ToastAndroid.show(message, ToastAndroid.SHORT);
};
+96
View File
@@ -0,0 +1,96 @@
import React from "react";
import { View } from "react-native";
import { createMaterialBottomTabNavigator } from "@react-navigation/material-bottom-tabs";
import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons";
import Library from "../Screens/Library/Library";
import Updates from "../Screens/Updates/Updates";
import History from "../Screens/History/History";
import Browse from "../Screens/Browse/Browse";
import More from "../Screens/More/More";
import { useTheme } from "../Hooks/reduxHooks";
const Tab = createMaterialBottomTabNavigator();
const BottomNavigator = () => {
const theme = useTheme();
return (
<View style={{ flex: 1, backgroundColor: theme.colorPrimaryDark }}>
<Tab.Navigator
barStyle={{ backgroundColor: theme.colorPrimary }}
activeColor={theme.colorAccent}
>
<Tab.Screen
name="Library"
component={Library}
options={{
tabBarIcon: ({ color }) => (
<MaterialCommunityIcons
name="book-variant-multiple"
color={color}
size={24}
/>
),
}}
/>
<Tab.Screen
name="Updates"
component={Updates}
options={{
tabBarIcon: ({ color }) => (
<MaterialCommunityIcons
name="alert-decagram-outline"
color={color}
size={24}
/>
),
}}
/>
<Tab.Screen
name="History"
component={History}
options={{
tabBarIcon: ({ color }) => (
<MaterialCommunityIcons
name="history"
color={color}
size={24}
/>
),
}}
/>
<Tab.Screen
name="Browse"
component={Browse}
options={{
tabBarIcon: ({ color }) => (
<MaterialCommunityIcons
name="compass-outline"
color={color}
size={24}
/>
),
}}
/>
<Tab.Screen
name="More"
component={More}
options={{
tabBarIcon: ({ color }) => (
<MaterialCommunityIcons
name="dots-horizontal"
color={color}
size={24}
/>
),
}}
/>
</Tab.Navigator>
</View>
);
};
export default BottomNavigator;
+79
View File
@@ -0,0 +1,79 @@
import React from "react";
import {
createStackNavigator,
TransitionPresets,
} from "@react-navigation/stack";
import BottomNavigator from "./BottomNavigator";
import Novel from "../Screens/Novel/Novel";
import Chapter from "../Screens/Chapter/Chapter";
import Extension from "../Screens/Extension/Extension";
import GlobalSearch from "../Screens/Browse/GlobalSearch";
import Migration from "../Screens/Browse/Migration";
import SourceNovels from "../Screens/Browse/SourceNovels";
import MigrateNovel from "../Screens/Browse/MigrateNovel";
import MoreStack from "./More";
import { setStatusBarStyle } from "../Hooks/setStatusBarStyle";
import { View } from "react-native";
import { useTheme } from "../Hooks/reduxHooks";
import { githubUpdateChecker } from "../Hooks/githubUpdateChecker";
import NewUpdateDialog from "../Components/NewUpdateDialog";
import { NavigationContainer } from "@react-navigation/native";
const Stack = createStackNavigator();
const stackNavigatorConfig = {
headerShown: false,
};
const MainNavigator = () => {
const theme = useTheme();
setStatusBarStyle();
const { isNewVersion, latestRelease } = githubUpdateChecker() || {};
return (
<NavigationContainer
theme={{ colors: { background: theme.colorPrimaryDark } }}
>
<View style={{ flex: 1, backgroundColor: theme.colorPrimaryDark }}>
{isNewVersion && <NewUpdateDialog newVersion={latestRelease} />}
<Stack.Navigator screenOptions={stackNavigatorConfig}>
<Stack.Screen
name="BottomNavigator"
component={BottomNavigator}
/>
<Stack.Screen
name="Novel"
component={Novel}
options={{
...TransitionPresets.RevealFromBottomAndroid,
}}
/>
<Stack.Screen
name="Chapter"
component={Chapter}
options={{ ...TransitionPresets.SlideFromRightIOS }}
/>
<Stack.Screen name="MoreStack" component={MoreStack} />
<Stack.Screen name="Extension" component={Extension} />
<Stack.Screen
name="GlobalSearch"
component={GlobalSearch}
/>
<Stack.Screen name="Migration" component={Migration} />
<Stack.Screen
name="SourceNovels"
component={SourceNovels}
/>
<Stack.Screen
name="MigrateNovel"
component={MigrateNovel}
/>
</Stack.Navigator>
</View>
</NavigationContainer>
);
};
export default MainNavigator;
+64
View File
@@ -0,0 +1,64 @@
import React from "react";
import { View } from "react-native";
import { useTheme } from "../Hooks/reduxHooks";
import { createStackNavigator } from "@react-navigation/stack";
import About from "../Screens/More/About";
import Settings from "../Screens/More/Settings";
import TrackerSettings from "../Screens/More/TrackerSettings";
import ReaderSettings from "../Screens/More/ReaderSettings";
import BackupSettings from "../Screens/More/BackupSettings";
import AdvancedSettings from "../Screens/More/AdvancedSettings";
import GeneralSettings from "../Screens/More/GeneralSettings";
const Stack = createStackNavigator();
const stackNavigatorConfig = { headerShown: false };
const SettingsStack = () => {
const theme = useTheme();
return (
<View style={{ flex: 1, backgroundColor: theme.colorPrimaryDark }}>
<Stack.Navigator screenOptions={stackNavigatorConfig}>
<Stack.Screen name="Settings" component={Settings} />
<Stack.Screen
name="GeneralSettings"
component={GeneralSettings}
/>
<Stack.Screen
name="ReaderSettings"
component={ReaderSettings}
/>
<Stack.Screen
name="TrackerSettings"
component={TrackerSettings}
/>
<Stack.Screen
name="BackupSettings"
component={BackupSettings}
/>
<Stack.Screen
name="AdvancedSettings"
component={AdvancedSettings}
/>
</Stack.Navigator>
</View>
);
};
const MoreStack = () => {
const theme = useTheme();
return (
<View style={{ flex: 1, backgroundColor: theme.colorPrimaryDark }}>
<Stack.Navigator screenOptions={stackNavigatorConfig}>
<Stack.Screen name="SettingsStack" component={SettingsStack} />
<Stack.Screen name="About" component={About} />
</Stack.Navigator>
</View>
);
};
export default MoreStack;
+157
View File
@@ -0,0 +1,157 @@
import React, { useEffect, useState } from "react";
import {
StyleSheet,
View,
ScrollView,
Text,
FlatList,
ActivityIndicator,
RefreshControl,
} from "react-native";
import { useTheme } from "../../Hooks/reduxHooks";
import { useDispatch, useSelector } from "react-redux";
import {
getSourcesAction,
searchSourcesAction,
} from "../../redux/source/source.actions";
import SourceCard from "./components/SourceCard";
import { showToast } from "../../Hooks/showToast";
import { Searchbar } from "../../Components/Searchbar";
const Browse = ({ navigation }) => {
const [refreshing, setRefreshing] = useState(false);
const [searchText, setSearchText] = useState("");
// const [globalSearch, setGlobalSearch] = useState(false);
// const [searchResult, setSearchResults] = useState();
const { sources, search, loading, pinned } = useSelector(
(state) => state.sourceReducer
);
const theme = useTheme();
const dispatch = useDispatch();
const isPinned = (sourceId) =>
pinned.indexOf(sourceId) === -1 ? false : true;
useEffect(() => {
dispatch(getSourcesAction());
}, [getSourcesAction]);
const onRefresh = () => {
showToast("Updating extension list");
setRefreshing(true);
getSourcesAction();
setRefreshing(false);
};
const clearSearchbar = () => {
setSearchText("");
};
const onChangeText = (text) => {
setSearchText(text);
dispatch(searchSourcesAction(text));
};
// const onSubmitEditing = () => {};
const renderItem = ({ item }) => (
<SourceCard
item={item}
isPinned={isPinned(item.sourceId)}
theme={theme}
/>
);
return (
<View
style={[
styles.container,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<Searchbar
theme={theme}
placeholder="Search Source"
left="magnify"
onPressLeft={clearSearchbar}
searchText={searchText}
onChangeText={onChangeText}
clearSearchbar={clearSearchbar}
right="book-search"
onPressRight={() => navigation.navigate("GlobalSearch")}
migrate="swap-vertical-variant"
onPressMigrate={() => navigation.navigate("Migration")}
/>
<FlatList
contentContainerStyle={{ flexGrow: 1 }}
data={!searchText ? sources : search}
keyExtractor={(item) => item.sourceId.toString()}
renderItem={renderItem}
extraData={pinned}
ListHeaderComponent={
<View>
{pinned.length > 0 && (
<FlatList
contentContainerStyle={{ paddingBottom: 16 }}
data={sources.filter((source) =>
isPinned(source.sourceId)
)}
keyExtractor={(item) =>
item.sourceId.toString()
}
renderItem={renderItem}
extraData={pinned}
ListHeaderComponent={
<Text
style={{
color: theme.textColorSecondary,
paddingHorizontal: 16,
paddingVertical: 8,
}}
>
Pinned
</Text>
}
/>
)}
<Text
style={{
color: theme.textColorSecondary,
paddingHorizontal: 16,
paddingVertical: 8,
}}
>
Sources
</Text>
</View>
}
ListEmptyComponent={
loading && (
<ActivityIndicator
size="small"
color={theme.colorAccent}
style={{ marginTop: 16 }}
/>
)
}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={["white"]}
progressBackgroundColor={theme.colorAccent}
/>
}
/>
</View>
);
};
export default Browse;
const styles = StyleSheet.create({
container: { flex: 1 },
});
+194
View File
@@ -0,0 +1,194 @@
import React, { useEffect, useState } from "react";
import {
StyleSheet,
View,
Text,
FlatList,
ActivityIndicator,
} from "react-native";
import { useLibrary, useTheme } from "../../Hooks/reduxHooks";
import { useDispatch, useSelector } from "react-redux";
import { Searchbar } from "../../Components/Searchbar";
import NovelCover from "../../Components/NovelCover";
import NovelList from "../../Components/NovelList";
import EmptyView from "../../Components/EmptyView";
import GlobalSearchNovelList from "./components/GlobalSearchNovelList";
import { ProgressBar } from "react-native-paper";
import { showToast } from "../../Hooks/showToast";
const GlobalSearch = ({ route, navigation }) => {
const theme = useTheme();
let novelName = "";
if (route.params) {
novelName = route.params.novelName;
}
const [loading, setLoading] = useState(false);
const [searchText, setSearchText] = useState(novelName);
const [searchResults, setSearchResults] = useState("");
const { sources, pinned } = useSelector((state) => state.sourceReducer);
const [progress, setProgress] = useState(0);
const library = useLibrary();
const pinnedSources = sources.filter(
(source) => pinned.indexOf(source.sourceId) !== -1
);
useEffect(() => {
novelName && onSubmitEditing();
}, []);
const clearSearchbar = () => {
setSearchText("");
};
const onChangeText = (text) => setSearchText(text);
const onSubmitEditing = () => {
setSearchResults("");
const getSearchUrl = (sourceId) => {
if (sourceId === 1) {
return `https://lnreader-extensions.vercel.app/api/1/search/?s=${searchText}&?o=rating`;
} else {
return `https://lnreader-extensions.vercel.app/api/${sourceId}/search/?s=${searchText}`;
}
};
pinnedSources.map((source, index) =>
setTimeout(() => {
setLoading(true);
fetch(getSearchUrl(source.sourceId))
.then((response) => response.json())
.then((json) => {
setSearchResults((searchResults) => [
...searchResults,
{
sourceId: source.sourceId,
sourceName: source.sourceName,
sourceLanguage: source.sourceLanguage,
novels: json,
},
]);
setLoading(false);
})
.catch((error) => {
showToast(error.message);
setSearchResults((searchResults) => [
...searchResults,
{
sourceId: source.sourceId,
sourceName: source.sourceName,
sourceLanguage: source.sourceLanguage,
novels: [],
},
]);
setLoading(false);
})
.finally(() =>
setProgress(
(progress) => progress + 1 / pinnedSources.length
)
);
}, 1000 * index)
);
};
const renderItem = ({ item }) => (
<>
<View style={{ padding: 8, paddingVertical: 16 }}>
<Text style={{ color: theme.textColorPrimary }}>
{item.sourceName}
</Text>
<Text style={{ color: theme.textColorSecondary, fontSize: 12 }}>
{item.sourceLanguage}
</Text>
</View>
<GlobalSearchNovelList
data={item.novels}
theme={theme}
library={library}
navigation={navigation}
/>
</>
);
return (
<View
style={[
styles.container,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<Searchbar
theme={theme}
placeholder="Global Search"
left="arrow-left"
onPressLeft={() => navigation.goBack()}
searchText={searchText}
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
clearSearchbar={clearSearchbar}
/>
{progress < 1 && progress > 0 && pinned && (
<ProgressBar
color={
progress > 0.9
? theme.colorPrimaryDark
: theme.colorAccent
}
progress={progress}
/>
)}
<FlatList
contentContainerStyle={{
flexGrow: 1,
padding: 4,
}}
data={searchResults}
keyExtractor={(item) => item.sourceId.toString()}
renderItem={renderItem}
extraData={pinned}
ListEmptyComponent={
<>
{!loading && (
<EmptyView
icon="__φ(..)"
description={`Search a novel in your pinned sources ${
pinned.length === 0
? "(No sources pinned)"
: ""
}`}
/>
)}
</>
}
ListFooterComponent={
loading && (
<View
style={{
flex: 1,
justifyContent: "center",
padding: 16,
}}
>
<ActivityIndicator
size="large"
color={theme.colorAccent}
/>
</View>
)
}
/>
</View>
);
};
export default GlobalSearch;
const styles = StyleSheet.create({
container: { flex: 1 },
});
+168
View File
@@ -0,0 +1,168 @@
import React, { useEffect, useState } from "react";
import {
StyleSheet,
View,
Text,
FlatList,
ActivityIndicator,
} from "react-native";
import { ProgressBar } from "react-native-paper";
import { useLibrary, useTheme } from "../../Hooks/reduxHooks";
import { useSelector } from "react-redux";
import EmptyView from "../../Components/EmptyView";
import MigrationNovelList from "./components/MigrationNovelList";
import { Appbar } from "../../Components/Appbar";
import { showToast } from "../../Hooks/showToast";
const GlobalSearch = ({ navigation, route }) => {
const { sourceId, novelName } = route.params;
const theme = useTheme();
const [progress, setProgress] = useState(0);
const [loading, setLoading] = useState(false);
const [searchResults, setSearchResults] = useState("");
const { sources, pinned } = useSelector((state) => state.sourceReducer);
const library = useLibrary();
const pinnedSources = sources.filter(
(source) =>
pinned.indexOf(source.sourceId) !== -1 &&
source.sourceId !== sourceId
);
const getSearchResults = () => {
const getSearchUrl = (sourceId) => {
if (sourceId === 1) {
return `https://lnreader-extensions.vercel.app/api/1/search/?s=${novelName}&?o=rating`;
} else {
return `https://lnreader-extensions.vercel.app/api/${sourceId}/search/?s=${novelName}`;
}
};
pinnedSources.map((source, index) =>
setTimeout(() => {
setLoading(true);
fetch(getSearchUrl(source.sourceId))
.then((response) => response.json())
.then((json) => {
setSearchResults((searchResults) => [
...searchResults,
{
sourceId: source.sourceId,
sourceName: source.sourceName,
sourceLanguage: source.sourceLanguage,
novels: json,
},
]);
setLoading(false);
})
.catch((error) => {
showToast(error.message);
setSearchResults((searchResults) => [
...searchResults,
{
sourceId: source.sourceId,
sourceName: source.sourceName,
sourceLanguage: source.sourceLanguage,
novels: [],
},
]);
setLoading(false);
})
.finally(() =>
setProgress(
(progress) => progress + 1 / pinnedSources.length
)
);
}, 1000 * index)
);
};
useEffect(() => {
getSearchResults();
}, []);
const renderItem = ({ item }) => (
<>
<View style={{ padding: 8, paddingVertical: 16 }}>
<Text style={{ color: theme.textColorPrimary }}>
{item.sourceName}
</Text>
<Text style={{ color: theme.textColorSecondary, fontSize: 12 }}>
{item.sourceLanguage}
</Text>
</View>
<MigrationNovelList
data={item.novels}
theme={theme}
library={library}
navigation={navigation}
/>
</>
);
return (
<View
style={[
styles.container,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<Appbar
title={novelName}
onBackAction={() => navigation.goBack()}
/>
{progress < 1 && pinned && (
<ProgressBar color={theme.colorAccent} progress={progress} />
)}
<FlatList
contentContainerStyle={{
flexGrow: 1,
padding: 4,
}}
data={searchResults}
keyExtractor={(item) => item.sourceId.toString()}
renderItem={renderItem}
extraData={pinned}
ListEmptyComponent={
<>
{!loading && (
<EmptyView
icon="__φ(..)"
description={`Search a novel in your pinned sources ${
pinned.length === 0
? "(No sources pinned)"
: ""
}`}
/>
)}
</>
}
ListFooterComponent={
loading && (
<View
style={{
flex: 1,
justifyContent: "center",
padding: 16,
}}
>
<ActivityIndicator
size="large"
color={theme.colorAccent}
/>
</View>
)
}
/>
</View>
);
};
export default GlobalSearch;
const styles = StyleSheet.create({
container: { flex: 1 },
});
+69
View File
@@ -0,0 +1,69 @@
import React from "react";
import { StyleSheet, View, FlatList, Text } from "react-native";
import { useLibrary, useTheme } from "../../Hooks/reduxHooks";
import { useSelector } from "react-redux";
import { Appbar } from "../../Components/Appbar";
import MigrationSourceCard from "./components/MigrationSourceCard";
const GlobalSearch = ({ navigation }) => {
const theme = useTheme();
const { sources } = useSelector((state) => state.sourceReducer);
const library = useLibrary();
const novelsPerSource = (sourceId) =>
library.filter((novel) => novel.sourceId === sourceId).length;
const renderItem = ({ item }) => (
<MigrationSourceCard
item={item}
theme={theme}
noOfNovels={novelsPerSource(item.sourceId)}
buttonLabel="All"
onPress={() => navigation.navigate("SourceNovels", item.sourceId)}
/>
);
return (
<View
style={[
styles.container,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<Appbar
title="Select Source"
onBackAction={() => navigation.goBack()}
/>
<FlatList
data={sources}
keyExtractor={(item) => item.sourceId.toString()}
renderItem={renderItem}
ListHeaderComponent={
<Text
style={{
color: theme.textColorSecondary,
padding: 20,
paddingBottom: 10,
textTransform: "uppercase",
}}
>
Select a Source To Migrate From
</Text>
}
/>
</View>
);
};
export default GlobalSearch;
const styles = StyleSheet.create({
container: { flex: 1 },
containerStyle: {
padding: 20,
margin: 20,
borderRadius: 6,
},
});
+83
View File
@@ -0,0 +1,83 @@
import React from "react";
import { StyleSheet, View, FlatList, Text } from "react-native";
import { useLibrary, useTheme } from "../../Hooks/reduxHooks";
import EmptyView from "../../Components/EmptyView";
import { Appbar } from "../../Components/Appbar";
import ListView from "../../Components/ListView";
const SourceNovels = ({ navigation, route }) => {
const sourceId = route.params;
const theme = useTheme();
const library = useLibrary();
const sourceNovels = library.filter((novel) => novel.sourceId === sourceId);
const renderItem = ({ item }) => (
<ListView
item={item}
theme={theme}
onPress={() =>
navigation.navigate("MigrateNovel", {
sourceId: item.sourceId,
novelName: item.novelName,
})
}
/>
);
return (
<View
style={[
styles.container,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<Appbar
title="Select Novel"
onBackAction={() => navigation.goBack()}
/>
<FlatList
data={sourceNovels}
keyExtractor={(item) => item.novelId.toString()}
renderItem={renderItem}
ListEmptyComponent={
<Text
style={{
color: theme.textColorSecondary,
padding: 20,
textAlign: "center",
}}
>
Your library does not have any novels from this source
</Text>
}
/>
{/* <Portal>
<MigrationDialog
migrationDialog={migrationDialog}
hideMigrationDialog={hideMigrationDIalog}
theme={theme}
selectedNovel={selectedNovel}
loading={loading}
migrationNovel={migrationNovel}
/>
<SourceToMigrateDialog
selectedNovel={selectedNovel}
sourceToMigrateDialog={sourceToMigrateDialog}
hideSourceToMigrateDialog={hideSourceToMigrateDialog}
sources={sources}
theme={theme}
showMigrationDialog={showMigrationDialog}
selectSourceToMigrate={selectSourceToMigrate}
/>
</Portal> */}
</View>
);
};
export default SourceNovels;
const styles = StyleSheet.create({
container: { flex: 1 },
});
@@ -0,0 +1,71 @@
import React from "react";
import { StyleSheet, View, Text, ImageBackground } from "react-native";
import { TouchableRipple } from "react-native-paper";
import { useTheme } from "../../../Hooks/reduxHooks";
const GlobalSearchNovelCover = ({ item, onPress, libraryStatus }) => {
const theme = useTheme();
const comfortableTitle = () => (
<Text
numberOfLines={2}
style={[
styles.title,
{
width: 115,
color: theme.textColorPrimary,
padding: 4,
flexWrap: "wrap",
},
]}
>
{item.novelName}
</Text>
);
return (
<View style={{ flex: 1 }}>
<TouchableRipple
borderless
centered
rippleColor={theme.rippleColor}
style={styles.opac}
onPress={onPress}
>
<>
<ImageBackground
source={{ uri: item.novelCover }}
style={{ height: 150, width: 115 }}
imageStyle={[
{ borderRadius: 4 },
libraryStatus && { opacity: 0.5 },
]}
progressiveRenderingEnabled={true}
/>
{comfortableTitle()}
</>
</TouchableRipple>
</View>
);
};
export default GlobalSearchNovelCover;
const styles = StyleSheet.create({
titleContainer: {
flex: 1,
justifyContent: "flex-end",
borderRadius: 4,
},
title: {
fontFamily: "pt-sans-bold",
fontSize: 14,
padding: 8,
},
opac: {
paddingHorizontal: 5,
paddingVertical: 4,
borderRadius: 4,
},
});
@@ -0,0 +1,55 @@
import React from "react";
import { StyleSheet, FlatList, Text } from "react-native";
import GlobalSearchNovelCover from "./GlobalSearchNovelCover";
const GlobalSearchNovelList = ({ data, theme, library, navigation }) => {
const checkIFInLibrary = (sourceId, novelUrl) => {
return library.some(
(obj) => obj.novelUrl === novelUrl && obj.sourceId === sourceId
);
};
const renderItem = ({ item }) => (
<GlobalSearchNovelCover
item={item}
onPress={() =>
navigation.navigate("Novel", {
...item,
sourceId: item.extensionId,
})
}
libraryStatus={checkIFInLibrary(item.extensionId, item.novelUrl)}
/>
);
return (
<FlatList
contentContainerStyle={styles.flatListCont}
horizontal={true}
data={data}
keyExtractor={(item) => item.novelUrl}
renderItem={renderItem}
ListEmptyComponent={
<Text
style={{
color: theme.textColorSecondary,
padding: 8,
paddingVertical: 4,
}}
>
No results found
</Text>
}
/>
);
};
export default GlobalSearchNovelList;
const styles = StyleSheet.create({
flatListCont: {
flexGrow: 1,
paddingVertical: 8,
paddingHorizontal: 4,
},
});
@@ -0,0 +1,132 @@
import React, { useState } from "react";
import { StyleSheet, FlatList, Text, View } from "react-native";
import { Portal, Modal, Button } from "react-native-paper";
import { migrateNovel } from "../../../Database/queries/NovelQueries";
import { showToast } from "../../../Hooks/showToast";
import GlobalSearchNovelCover from "./GlobalSearchNovelCover";
const MigrationNovelList = ({ data, theme, library, navigation }) => {
const [selectedNovel, setSelectedNovel] = useState(false);
const [migrateNovelDialog, setMigrateNovelDialog] = useState(false);
const showMigrateNovelDialog = () => setMigrateNovelDialog(true);
const hideMigrateNovelDialog = () => setMigrateNovelDialog(false);
const checkIFInLibrary = (sourceId, novelUrl) => {
return library.some(
(obj) => obj.novelUrl === novelUrl && obj.sourceId === sourceId
);
};
const renderItem = ({ item }) => (
<GlobalSearchNovelCover
item={item}
onPress={() =>
showModal(item.extensionId, item.novelUrl, item.novelName)
}
libraryStatus={checkIFInLibrary(item.extensionId, item.novelUrl)}
/>
);
const showModal = (sourceId, novelUrl, novelName) => {
if (checkIFInLibrary(sourceId, novelUrl)) {
showToast("Novel already in library");
} else {
setSelectedNovel({ sourceId, novelUrl, novelName });
showMigrateNovelDialog();
}
};
return (
<>
<FlatList
contentContainerStyle={styles.flatListCont}
horizontal={true}
data={data}
keyExtractor={(item) => item.novelUrl}
renderItem={renderItem}
ListEmptyComponent={
<Text
style={{
color: theme.textColorSecondary,
padding: 8,
paddingVertical: 4,
}}
>
No results found
</Text>
}
/>
<Portal>
<Modal
visible={migrateNovelDialog}
onDismiss={hideMigrateNovelDialog}
contentContainerStyle={{
padding: 20,
margin: 20,
borderRadius: 6,
backgroundColor: theme.colorPrimaryDark,
}}
>
<Text
style={{
color: theme.textColorPrimary,
fontSize: 18,
}}
>
{`Migrate ${selectedNovel.novelName}?`}
</Text>
<View
style={{
flexDirection: "row",
justifyContent: "flex-end",
}}
>
<Button
style={{ marginTop: 30 }}
labelStyle={{
color: theme.colorAccent,
letterSpacing: 0,
textTransform: "none",
}}
onPress={hideMigrateNovelDialog}
>
Cancel
</Button>
<Button
style={{ marginTop: 30 }}
labelStyle={{
color: theme.colorAccent,
letterSpacing: 0,
textTransform: "none",
}}
theme={{ colors: { primary: theme.colorAccent } }}
onPress={() => {
migrateNovel(
selectedNovel.sourceId,
selectedNovel.novelUrl
);
hideMigrateNovelDialog();
showToast(
`${selectedNovel.novelName} migrated to new source.`
);
}}
>
Migrate
</Button>
</View>
</Modal>
</Portal>
</>
);
};
export default MigrationNovelList;
const styles = StyleSheet.create({
flatListCont: {
flexGrow: 1,
paddingVertical: 8,
paddingHorizontal: 4,
},
});
@@ -0,0 +1,74 @@
import React from "react";
import { View, Text, Image, StyleSheet } from "react-native";
import { TouchableRipple } from "react-native-paper";
const MigrationSourceCard = ({ item, theme, noOfNovels, onPress }) => {
const { sourceId, sourceName, sourceCover, sourceLanguage, status } = item;
return (
<TouchableRipple
style={styles.extensionCard}
onPress={onPress}
rippleColor={theme.rippleColor}
>
<>
<Image
source={{ uri: sourceCover }}
style={styles.extensionIcon}
resizeMode="contain"
/>
<View style={styles.extensionDetails}>
<View>
<Text
style={{
color: theme.textColorPrimary,
fontSize: 14,
}}
>
{sourceName} {` (${noOfNovels || 0})`}
</Text>
<View style={{ flexDirection: "row" }}>
<Text
style={{
color: theme.textColorSecondary,
fontSize: 12,
}}
>
{sourceLanguage}
</Text>
</View>
</View>
</View>
</>
</TouchableRipple>
);
};
export default MigrationSourceCard;
const styles = StyleSheet.create({
extensionCard: {
flexDirection: "row",
alignItems: "center",
marginVertical: 4,
paddingVertical: 8,
paddingHorizontal: 20,
},
extensionIcon: {
width: 40,
height: 40,
borderRadius: 8,
},
extensionDetails: {
flex: 1,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginLeft: 16,
},
sourceStatus: {
color: "#C14033",
fontSize: 12,
marginLeft: 5,
},
});
+114
View File
@@ -0,0 +1,114 @@
import React from "react";
import { View, Text, Image, StyleSheet } from "react-native";
import { TouchableRipple, Button, IconButton } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import { useDispatch } from "react-redux";
import { pinSourceAction } from "../../../redux/source/source.actions";
const ExtensionCard = ({ item, theme, isPinned }) => {
const { sourceId, sourceName, sourceCover, sourceLanguage, status } = item;
const navigation = useNavigation();
const dispatch = useDispatch();
const navigateToExtension = () =>
navigation.navigate("Extension", {
sourceId: item.sourceId,
sourceName: item.sourceName,
sourceUrl: item.sourceUrl,
});
return (
<TouchableRipple
style={styles.extensionCard}
onPress={navigateToExtension}
rippleColor={theme.rippleColor}
>
<>
<Image
source={{ uri: sourceCover }}
style={styles.extensionIcon}
resizeMode="contain"
/>
<View style={styles.extensionDetails}>
<View>
<Text
style={{
color: theme.textColorPrimary,
fontSize: 14,
}}
>
{sourceName}
</Text>
<View style={{ flexDirection: "row" }}>
<Text
style={{
color: theme.textColorSecondary,
fontSize: 12,
}}
>
{sourceLanguage}
</Text>
<Text style={styles.sourceStatus}>
{status === 0 && "• Down"}
</Text>
</View>
</View>
<View
style={{ flexDirection: "row", alignItems: "center" }}
>
<Button
labelStyle={{ letterSpacing: 0 }}
uppercase={false}
color={theme.colorAccent}
onPress={navigateToExtension}
>
Browse
</Button>
<IconButton
icon={isPinned ? "pin" : "pin-outline"}
animated
size={21}
onPress={() => dispatch(pinSourceAction(sourceId))}
color={
isPinned
? theme.colorAccent
: theme.textColorSecondary
}
style={{ margin: 2 }}
/>
</View>
</View>
</>
</TouchableRipple>
);
};
export default ExtensionCard;
const styles = StyleSheet.create({
extensionCard: {
flexDirection: "row",
alignItems: "center",
marginVertical: 4,
paddingVertical: 8,
paddingHorizontal: 20,
},
extensionIcon: {
width: 40,
height: 40,
borderRadius: 8,
},
extensionDetails: {
flex: 1,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginLeft: 16,
},
sourceStatus: {
color: "#C14033",
fontSize: 12,
marginLeft: 5,
},
});
+301
View File
@@ -0,0 +1,301 @@
import React, { useState, useEffect, useRef } from "react";
import { StyleSheet, View, Text, ActivityIndicator } from "react-native";
import { useDispatch } from "react-redux";
import Constants from "expo-constants";
import { IconButton, Portal } from "react-native-paper";
import { CollapsibleHeaderScrollView } from "react-native-collapsible-header-views";
import { getChapterFromDB } from "../../Database/queries/ChapterQueries";
import { fetchChapter } from "../../Services/Source/source";
import { showToast } from "../../Hooks/showToast";
import {
usePosition,
useReaderSettings,
useTheme,
useTrackingStatus,
} from "../../Hooks/reduxHooks";
import { updateChaptersRead } from "../../redux/tracker/tracker.actions";
import { insertHistoryAction } from "../../redux/history/history.actions";
import {
errorTextColor,
readerBackground,
readerLineHeight,
readerTextColor,
} from "./readerStyleController";
import { markChapterReadAction } from "../../redux/novel/novel.actions";
import { saveScrollPosition } from "../../redux/preferences/preference.actions";
import { parseChapterNumber } from "../../Services/updates";
import ChapterAppbar from "./components/ChapterAppbar";
import ReaderSheet from "./components/ReaderSheet";
import EmptyView from "../../Components/EmptyView";
import FitImage from "react-native-fit-image";
const Chapter = ({ route, navigation }) => {
const {
sourceId,
chapterId,
chapterUrl,
novelId,
novelUrl,
novelName,
chapterName,
} = route.params;
let scrollViewRef;
let readerSheetRef = useRef(null);
const theme = useTheme();
const dispatch = useDispatch();
const reader = useReaderSettings();
const { tracker, trackedNovels } = useTrackingStatus();
const position = usePosition(novelId, chapterId);
const isTracked = trackedNovels.find((obj) => obj.novelId === novelId);
const [chapter, setChapter] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState();
const [scrollPercentage, setScrollPercentage] = useState(0);
const [firstLayout, setFirstLayout] = useState(true);
const [selectText, setSelectText] = useState(false);
const [images, setImages] = useState([]);
const getChapter = async (chapterId) => {
try {
if (chapterId) {
const chapterDownloaded = await getChapterFromDB(chapterId);
if (chapterDownloaded) {
setChapter(chapterDownloaded);
parseImages(chapterDownloaded.chapterText);
} else {
const res = await fetchChapter(
sourceId,
novelUrl,
chapterUrl
);
parseImages(res.chapterText);
setChapter(res);
}
} else {
const res = await fetchChapter(sourceId, novelUrl, chapterUrl);
parseImages(res.chapterText);
setChapter(res);
}
setLoading(false);
} catch (error) {
setError(error.message);
setLoading(false);
showToast(error.message);
}
};
const parseImages = (chapterText) => {
let imageLinks = chapterText.match(/\[https.*?\]/g, "");
let chapterTextWithoutLinks;
if (imageLinks) {
imageLinks = imageLinks.map((link) => link.slice(1, -1));
setImages(imageLinks);
}
};
useEffect(() => {
getChapter(chapterId);
dispatch(insertHistoryAction(novelId, chapterId));
}, []);
const isCloseToBottom = ({
layoutMeasurement,
contentOffset,
contentSize,
}) => {
const paddingToBottom = 40;
return (
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom
);
};
const updateTracker = () => {
const chapterNumber = parseChapterNumber(chapterName);
isTracked &&
chapterNumber &&
Number.isInteger(chapterNumber) &&
chapterNumber > isTracked.my_list_status.num_chapters_read &&
dispatch(
updateChaptersRead(
isTracked.id,
tracker.access_token,
chapterNumber
)
);
};
const onScroll = ({ nativeEvent }) => {
const offsetY = nativeEvent.contentOffset.y;
const position =
nativeEvent.contentOffset.y + nativeEvent.layoutMeasurement.height;
const percentage = Math.round(
(position / nativeEvent.contentSize.height) * 100
);
setScrollPercentage(percentage);
dispatch(saveScrollPosition(offsetY, percentage, chapterId, novelId));
if (isCloseToBottom(nativeEvent)) {
dispatch(markChapterReadAction(chapterId, novelId));
updateTracker();
}
};
const scrollToSavedProgress = () => {
if (position && firstLayout) {
position.percentage < 100 &&
scrollViewRef.getNode().scrollTo({
x: 0,
y: position.position,
animated: false,
});
setFirstLayout(false);
}
};
const readerStyles = [
{
paddingVertical: 16,
paddingBottom: 32,
fontSize: reader.textSize,
color: readerTextColor(reader.theme),
lineHeight: readerLineHeight(reader.textSize, reader.lineHeight),
textAlign: reader.textAlign,
paddingHorizontal: `${reader.padding}%`,
},
reader.fontFamily && {
fontFamily: reader.fontFamily,
},
];
return (
<>
<CollapsibleHeaderScrollView
headerContainerBackgroundColor="rgba(0,0,0,0.5)"
ref={(ref) => (scrollViewRef = ref)}
CollapsibleHeaderComponent={
<ChapterAppbar
navigation={navigation}
sourceId={sourceId}
novelId={novelId}
novelUrl={novelUrl}
novelName={novelName}
chapterName={chapterName}
chapterId={chapterId}
chapter={chapter}
theme={theme}
readerSheetRef={readerSheetRef}
/>
}
headerHeight={Constants.statusBarHeight + 56}
contentContainerStyle={[
styles.screenContainer,
{ backgroundColor: readerBackground(reader.theme) },
]}
onScroll={onScroll}
showsVerticalScrollIndicator={false}
>
{error ? (
<View style={{ flex: 1, justifyContent: "center" }}>
<EmptyView
icon="(-_-;)・・・"
description={error}
style={{ color: errorTextColor(reader.theme) }}
>
<IconButton
icon="reload"
size={25}
style={{ margin: 0, marginTop: 16 }}
color={errorTextColor(reader.theme)}
onPress={() => {
getChapter(chapterId);
setLoading(true);
setError();
}}
/>
<Text
style={{ color: errorTextColor(reader.theme) }}
>
Retry
</Text>
</EmptyView>
</View>
) : loading ? (
<View style={{ flex: 1, justifyContent: "center" }}>
<ActivityIndicator
size={50}
color={theme.colorAccent}
/>
</View>
) : (
<View style={{ flex: 1 }}>
{images.length > 0 &&
images.map((image, index) => (
<FitImage source={{ uri: image }} />
))}
<Text
style={readerStyles}
onLayout={scrollToSavedProgress}
selectable={selectText}
>
{chapter.chapterText
.trim()
.replace(/\[https.*?\]/g, "")}
</Text>
</View>
)}
<Portal>
<ReaderSheet
theme={theme}
reader={reader}
dispatch={dispatch}
bottomSheetRef={readerSheetRef}
selectText={selectText}
setSelectText={setSelectText}
/>
</Portal>
</CollapsibleHeaderScrollView>
<Text
style={[
styles.scrollPercentage,
{ color: readerTextColor(reader.theme) },
]}
>
{scrollPercentage + "%"}
</Text>
</>
);
};
export default Chapter;
const styles = StyleSheet.create({
screenContainer: {
flexGrow: 1,
paddingVertical: 10,
},
scrollPercentage: {
position: "absolute",
bottom: 12,
alignSelf: "center",
fontFamily: "noto-sans",
fontWeight: "bold",
zIndex: 1,
},
});
@@ -0,0 +1,95 @@
import React, { useEffect, useState } from "react";
import { Appbar } from "react-native-paper";
import {
getNextChapterFromDB,
getPrevChapterFromDB,
} from "../../../Database/queries/ChapterQueries";
const ChapterAppbar = ({
navigation,
sourceId,
novelId,
novelName,
novelUrl,
chapterId,
chapterName,
theme,
readerSheetRef,
}) => {
const [nextChapter, setNextChapter] = useState({});
const [prevChapter, setPrevChapter] = useState({});
const setPrevAndNextChap = async () => {
const nextChap = await getNextChapterFromDB(novelId, chapterId);
const prevChap = await getPrevChapterFromDB(novelId, chapterId);
setNextChapter(nextChap);
setPrevChapter(prevChap);
};
useEffect(() => {
setPrevAndNextChap();
}, []);
const navigateToPrevChapter = () =>
navigation.replace("Chapter", {
chapterUrl: prevChapter.chapterUrl,
chapterId: prevChapter.chapterId,
sourceId,
novelUrl,
novelId,
chapterName: prevChapter.chapterName,
novelName,
});
const navigateToNextChapter = () =>
navigation.replace("Chapter", {
chapterUrl: nextChapter.chapterUrl,
sourceId,
novelUrl,
novelId,
chapterId: nextChapter.chapterId,
chapterName: nextChapter.chapterName,
novelName,
});
return (
<Appbar.Header style={{ backgroundColor: "transparent", elevation: 0 }}>
<Appbar.BackAction
onPress={() => navigation.goBack()}
color="#FFFFFF"
size={26}
style={{ marginRight: 0 }}
/>
<Appbar.Content
title={novelName}
titleStyle={{ color: "#FFFFFF" }}
subtitle={chapterName}
subtitleStyle={{ color: "rgba(255, 255, 255, 0.7)" }}
/>
<Appbar.Action
icon="chevron-left"
size={26}
disabled={!prevChapter}
onPress={navigateToPrevChapter}
color="#FFFFFF"
/>
<Appbar.Action
icon="chevron-right"
size={26}
disabled={!nextChapter}
onPress={navigateToNextChapter}
color="#FFFFFF"
/>
<Appbar.Action
icon="dots-vertical"
size={26}
onPress={() => readerSheetRef.current.show()}
color="#FFFFFF"
/>
</Appbar.Header>
);
};
export default ChapterAppbar;
@@ -0,0 +1,403 @@
import React, { useState } from "react";
import { StyleSheet, View, Text, Dimensions } from "react-native";
import { ToggleButton, IconButton, Menu, Switch } from "react-native-paper";
import Slider from "@react-native-community/slider";
import Bottomsheet from "rn-sliding-up-panel";
import { fonts } from "../../../Services/utils/constants";
import { setReaderSettings } from "../../../redux/settings/settings.actions";
import BottomSheetHandle from "../../../Components/BottomSheetHandle";
import { Row } from "../../../Components/Common";
const ReaderSheet = ({
theme,
reader,
dispatch,
bottomSheetRef,
selectText,
setSelectText,
}) => {
const [fontMenu, setFontMenu] = useState(false);
const openFontMenu = () => setFontMenu(true);
const closeFontMenu = () => setFontMenu(false);
const ReaderSettingTitle = ({ title }) => (
<Text style={styles.title}>{title}</Text>
);
return (
<Bottomsheet
ref={bottomSheetRef}
draggableRange={{ top: 420, bottom: 0 }}
snappingPoints={[0, 420]}
showBackdrop={true}
backdropOpacity={0}
>
<View style={styles.contentContainer}>
<BottomSheetHandle theme={theme} />
<View style={styles.readerSettingsContainer}>
<ReaderSettingTitle title="Text Size" />
<Slider
style={{
width: Dimensions.get("window").width,
height: 40,
}}
value={reader.textSize}
minimumValue={12}
maximumValue={20}
step={2}
minimumTrackTintColor={theme.colorAccent}
maximumTrackTintColor="#000000"
thumbTintColor={theme.colorAccent}
onValueChange={(value) =>
dispatch(setReaderSettings("textSize", value))
}
/>
<Row
style={{
justifyContent: "space-between",
width: "100%",
paddingHorizontal: 16,
}}
>
<ReaderSettingTitle title="Reader Theme" />
<Row>
<ToggleButton.Row
onValueChange={(value) => {
dispatch(
setReaderSettings(
"theme",
value ?? reader.theme
)
);
}}
value={reader.theme}
>
<ToggleButton
icon={reader.theme === 1 && "check"}
color="#FFFFFF"
value={1}
style={{
backgroundColor: "#000000",
marginHorizontal: 10,
borderRadius: 50,
borderTopRightRadius: 50,
borderBottomRightRadius: 50,
}}
/>
<ToggleButton
icon={reader.theme === 4 && "check"}
color="#FFFFFF"
value={4}
style={{
backgroundColor: "#444444",
marginHorizontal: 10,
borderWidth: 0,
borderRadius: 50,
borderTopRightRadius: 50,
borderBottomRightRadius: 50,
}}
/>
<ToggleButton
icon={reader.theme === 2 && "check"}
color="#000000"
value={2}
style={{
backgroundColor: "#FFFFFF",
marginHorizontal: 10,
borderRadius: 50,
}}
/>
<ToggleButton
icon={reader.theme === 3 && "check"}
color="#000000"
value={3}
style={{
backgroundColor: "#F4ECD8",
marginHorizontal: 10,
borderRadius: 50,
borderTopLeftRadius: 50,
borderBottomLeftRadius: 50,
}}
/>
</ToggleButton.Row>
</Row>
</Row>
<Row
style={{
justifyContent: "space-between",
width: "100%",
paddingHorizontal: 16,
marginTop: 16,
}}
>
<ReaderSettingTitle title="Text Align" />
<Row>
<ToggleButton.Row
onValueChange={(value) =>
dispatch(
setReaderSettings(
"textAlign",
value ?? reader.textAlign
)
)
}
value={reader.textAlign}
>
<ToggleButton
icon="format-align-left"
color={
reader.textAlign === "left"
? theme.colorAccent
: "#FFFFFF"
}
value="left"
style={{
backgroundColor: "transparent",
marginHorizontal: 10,
borderWidth: 0,
borderTopRightRadius: 4,
borderBottomRightRadius: 4,
}}
/>
<ToggleButton
icon="format-align-justify"
color={
reader.textAlign === "justify"
? theme.colorAccent
: "#FFFFFF"
}
value="justify"
style={{
backgroundColor: "transparent",
marginHorizontal: 10,
borderWidth: 0,
borderRadius: 4,
}}
/>
<ToggleButton
icon="format-align-right"
color={
reader.textAlign === "right"
? theme.colorAccent
: "#FFFFFF"
}
value="right"
style={{
backgroundColor: "transparent",
marginHorizontal: 10,
borderWidth: 0,
borderTopLeftRadius: 4,
borderBottomLeftRadius: 4,
}}
/>
</ToggleButton.Row>
</Row>
</Row>
<Row
style={{
justifyContent: "space-between",
width: "100%",
paddingHorizontal: 16,
marginTop: 16,
}}
>
<ReaderSettingTitle title="Padding" />
<Row>
<IconButton
icon="minus"
color={theme.colorAccent}
size={26}
disabled={reader.padding <= 0 ? true : false}
onPress={() =>
dispatch(
setReaderSettings(
"padding",
reader.padding - 1
)
)
}
style={{ marginVertical: 0 }}
/>
<Text
style={{
color: "#FFFFFF",
paddingHorizontal: 24,
}}
>
{`${reader.padding}%`}
</Text>
<IconButton
icon="plus"
color={theme.colorAccent}
size={26}
disabled={reader.padding >= 10 ? true : false}
onPress={() =>
dispatch(
setReaderSettings(
"padding",
reader.padding + 1
)
)
}
style={{ marginVertical: 0 }}
/>
</Row>
</Row>
<Row
style={{
justifyContent: "space-between",
width: "100%",
paddingHorizontal: 16,
marginTop: 16,
}}
>
<ReaderSettingTitle title="Line Height" />
<Row>
<IconButton
icon="minus"
color={theme.colorAccent}
size={26}
disabled={
reader.lineHeight <= 1.3 ? true : false
}
onPress={() =>
dispatch(
setReaderSettings(
"lineHeight",
reader.lineHeight - 0.1
)
)
}
style={{ marginVertical: 0 }}
/>
<Text
style={{
color: "#FFFFFF",
paddingHorizontal: 24,
}}
>
{`${Math.round(reader.lineHeight * 10) / 10}%`}
</Text>
<IconButton
icon="plus"
color={theme.colorAccent}
size={26}
disabled={reader.lineHeight >= 2 ? true : false}
onPress={() =>
dispatch(
setReaderSettings(
"lineHeight",
reader.lineHeight + 0.1
)
)
}
style={{ marginVertical: 0 }}
/>
</Row>
</Row>
<Row
style={{
justifyContent: "space-between",
width: "100%",
paddingHorizontal: 16,
marginTop: 16,
}}
>
<ReaderSettingTitle title="Font Style" />
<Menu
visible={fontMenu}
onDismiss={closeFontMenu}
contentStyle={{ backgroundColor: theme.menuColor }}
anchor={
<Text
onPress={openFontMenu}
style={{
color: "white",
paddingHorizontal: 16,
fontSize: 16,
fontFamily: reader.fontFamily,
}}
>
{fonts.find(
(font) =>
font.fontFamily ===
reader.fontFamily
).name || "Default"}
</Text>
}
>
{fonts.map((font) => (
<Menu.Item
key={font.fontFamily}
onPress={() =>
dispatch(
setReaderSettings(
"fontFamily",
font.fontFamily
)
)
}
icon={
font.fontFamily === reader.fontFamily &&
"check"
}
title={font.name}
style={{ backgroundColor: theme.menuColor }}
titleStyle={{
color: theme.textColorPrimary,
}}
/>
))}
</Menu>
</Row>
<Row
style={{
justifyContent: "space-between",
width: "100%",
paddingHorizontal: 16,
marginTop: 16,
}}
>
<View>
<ReaderSettingTitle title="Select Text " />
<Text
style={{
fontSize: 14,
color: "white",
}}
>
(Justify does not work with selectable text)
</Text>
</View>
<Switch
value={selectText}
onValueChange={setSelectText}
color={theme.colorAccent}
/>
</Row>
</View>
</View>
</Bottomsheet>
);
};
export default ReaderSheet;
const styles = StyleSheet.create({
contentContainer: {
flex: 1,
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
backgroundColor: "rgba(0,0,0,0.5)",
},
readerSettingsContainer: {
flex: 1,
alignItems: "center",
paddingTop: 30,
},
title: {
color: "#FFFFFF",
fontWeight: "bold",
},
});
@@ -0,0 +1,31 @@
export const readerBackground = (val) => {
const backgroundColor = {
1: "#000000",
2: "#FFFFFF",
3: "#F4ECD8",
4: "#444444",
};
return backgroundColor[val] ?? "#FFFFFF";
};
export const readerTextColor = (val) => {
const textColor =
val === 1 ? "rgba(255,255,255,0.7)" : val === 4 ? "#FFFFFF" : "#000000";
return textColor;
};
export const readerLineHeight = (fontSize, lineHeightMultiplier) => {
let lineHeight = fontSize * lineHeightMultiplier;
lineHeight = Math.round(lineHeight);
return lineHeight;
};
export const errorTextColor = (val) => {
const textColor = val === 1 ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.54)";
return textColor;
};
+163
View File
@@ -0,0 +1,163 @@
import React, { useEffect, useState } from "react";
import { StyleSheet, View, ActivityIndicator } from "react-native";
import * as WebBrowser from "expo-web-browser";
import NovelCover from "../../Components/NovelCover";
import EmptyView from "../../Components/EmptyView";
import { Searchbar } from "../../Components/Searchbar";
import { useTheme, useLibrary } from "../../Hooks/reduxHooks";
import NovelList from "../../Components/NovelList";
import { showToast } from "../../Hooks/showToast";
import ErrorView from "../../Components/ErrorView";
const Extension = ({ navigation, route }) => {
const { sourceId, sourceName, sourceUrl } = route.params;
const theme = useTheme();
const library = useLibrary();
const [loading, setLoading] = useState(true);
const [novels, setNovels] = useState();
const [error, setError] = useState();
const [searchText, setSearchText] = useState("");
const getSourceUrl = () => {
if (sourceId === 1) {
return `https://lnreader-extensions.vercel.app/api/1/novels/1/?o=rating`;
} else {
return `https://lnreader-extensions.vercel.app/api/${sourceId}/novels/`;
}
};
const getSearchUrl = () => {
if (sourceId === 1) {
return `https://lnreader-extensions.vercel.app/api/1/search/?s=${searchText}&?o=rating`;
} else {
return `https://lnreader-extensions.vercel.app/api/${sourceId}/search/?s=${searchText}`;
}
};
const getNovels = async () => {
try {
const url = getSourceUrl();
const res = await fetch(url);
const data = await res.json();
setNovels(data);
setLoading(false);
} catch (error) {
setError(error.message);
setNovels([]);
setLoading(false);
showToast(error.message);
}
};
const clearSearchbar = () => {
getNovels();
setLoading(true);
setSearchText("");
};
const getSearchResults = () => {
setLoading(true);
fetch(getSearchUrl())
.then((response) => response.json())
.then((json) => {
setNovels(json);
setLoading(false);
})
.catch((error) => {
setNovels([]);
setLoading(false);
showToast(error.message);
});
};
const checkIFInLibrary = (sourceId, novelUrl) => {
return library.some(
(obj) => obj.novelUrl === novelUrl && obj.sourceId === sourceId
);
};
useEffect(() => {
getNovels();
}, []);
const renderItem = ({ item }) => (
<NovelCover
item={item}
onPress={() =>
navigation.navigate("Novel", {
novelName: item.novelName,
novelCover: item.novelCover,
novelUrl: item.novelUrl,
sourceId: item.extensionId,
})
}
libraryStatus={checkIFInLibrary(item.extensionId, item.novelUrl)}
/>
);
const listEmptyComponent = () => (
<ErrorView
error={error || "No results found"}
onRetry={() => {
getNovels();
setLoading(true);
setError();
}}
openWebView={() => WebBrowser.openBrowserAsync(sourceUrl)}
theme={theme}
/>
);
return (
<View
style={[
styles.container,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<Searchbar
theme={theme}
placeholder={`Search ${sourceName}`}
left="arrow-left"
onPressLeft={() => navigation.goBack()}
searchText={searchText}
onChangeText={(text) => setSearchText(text)}
onSubmitEditing={getSearchResults}
clearSearchbar={clearSearchbar}
right="earth"
displayMenu={true}
onPressRight={() => WebBrowser.openBrowserAsync(sourceUrl)}
/>
{loading ? (
<View style={{ flex: 1, justifyContent: "center" }}>
<ActivityIndicator size={60} color={theme.colorAccent} />
</View>
) : (
<NovelList
data={novels}
renderItem={renderItem}
ListEmptyComponent={listEmptyComponent()}
/>
)}
</View>
);
};
export default Extension;
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
flex: 1,
},
});
+171
View File
@@ -0,0 +1,171 @@
import React, { useState, useEffect } from "react";
import { StyleSheet, FlatList, ActivityIndicator, Text } from "react-native";
import HistoryCard from "./components/HistoryCard";
import { useDispatch, useSelector } from "react-redux";
import EmptyView from "../../Components/EmptyView";
import {
getHistoryAction,
clearAllHistoryAction,
} from "../../redux/history/history.actions";
import moment from "moment";
import { dateFormat } from "../../Services/utils/constants";
import { ScreenContainer } from "../../Components/Common";
import { Searchbar } from "../../Components/Searchbar";
import { useTheme } from "../../Hooks/reduxHooks";
import { Button, Dialog, Portal } from "react-native-paper";
const History = ({ navigation }) => {
const theme = useTheme();
const dispatch = useDispatch();
const { history, loading } = useSelector((state) => state.historyReducer);
const [searchText, setSearchText] = useState("");
const [searchResults, setSearchResults] = useState([]);
/**
* Confirm Clear History Dialog
*/
const [visible, setVisible] = useState(false);
const showDialog = () => setVisible(true);
const hideDialog = () => setVisible(false);
useEffect(() => {
dispatch(getHistoryAction());
}, [getHistoryAction]);
const renderHistoryCard = ({ item }) => (
<HistoryCard
dispatch={dispatch}
item={item}
theme={theme}
navigation={navigation}
/>
);
const clearSearchbar = () => setSearchText("");
const onChangeText = (text) => {
setSearchText(text);
let results = [];
text !== "" &&
history.map((item) => {
const date = item.date;
const novels = item.novels.filter((novel) =>
novel.novelName.toLowerCase().includes(text.toLowerCase())
);
if (novels.length > 0) {
results.push({
date,
novels,
});
}
});
setSearchResults(results);
};
return (
<ScreenContainer theme={theme}>
<Searchbar
placeholder="Search History"
searchText={searchText}
clearSearchbar={clearSearchbar}
onChangeText={onChangeText}
left="magnify"
theme={theme}
right="trash-can-outline"
onPressRight={showDialog}
/>
<FlatList
contentContainerStyle={{
flexGrow: 1,
paddingVertical: 8,
paddingHorizontal: 16,
}}
data={searchText ? searchResults : history}
keyExtractor={(index) => index.toString()}
renderItem={({ item }) => (
<FlatList
keyExtractor={(item, index) => index.toString()}
data={item.novels}
renderItem={renderHistoryCard}
ListHeaderComponent={
<Text
style={{
textTransform: "uppercase",
paddingVertical: 8,
color: theme.textColorSecondary,
}}
>
{moment(item.date).calendar(null, dateFormat)}
</Text>
}
/>
)}
ListFooterComponent={
loading && (
<ActivityIndicator
size="small"
color={theme.colorAccent}
/>
)
}
ListEmptyComponent={
<EmptyView
icon="(˘・_・˘)"
description="Nothing read recently"
/>
}
/>
<Portal>
<Dialog
visible={visible}
onDismiss={hideDialog}
style={{
borderRadius: 6,
backgroundColor: theme.colorPrimary,
}}
>
<Dialog.Title
style={{
letterSpacing: 0,
fontSize: 16,
color: theme.textColorPrimary,
}}
>
Are you sure? All history will be lost.
</Dialog.Title>
<Dialog.Actions>
<Button
uppercase={false}
theme={{ colors: { primary: theme.colorAccent } }}
onPress={hideDialog}
>
Cancel
</Button>
<Button
uppercase={false}
theme={{ colors: { primary: theme.colorAccent } }}
onPress={() => {
dispatch(clearAllHistoryAction());
hideDialog();
}}
>
Ok
</Button>
</Dialog.Actions>
</Dialog>
</Portal>
</ScreenContainer>
);
};
export default History;
const styles = StyleSheet.create({});
@@ -5,24 +5,21 @@ import { TouchableRipple, IconButton } from "react-native-paper";
import moment from "moment";
import { useSelector } from "react-redux";
const HistoryCard = ({ item, deleteHistoryAction, navigation }) => {
const theme = useSelector((state) => state.themeReducer.theme);
import { useDispatch } from "react-redux";
import { setNovel } from "../../../redux/novel/novel.actions";
import { parseChapterNumber } from "../../../Services/utils/helpers";
import { deleteHistoryAction } from "../../../redux/history/history.actions";
const HistoryCard = ({ item, navigation, theme, dispatch }) => {
return (
<TouchableRipple
style={styles.historyCard}
rippleColor={theme.rippleColor}
borderless
onPress={() =>
navigation.navigate("NovelItem", {
novelName: item.novelName,
novelCover: item.novelCover,
extensionId: item.extensionId,
novelUrl: item.novelUrl,
})
}
onPress={() => {
navigation.navigate("Novel", item);
dispatch(setNovel(item));
}}
>
<>
<Image
@@ -48,9 +45,11 @@ const HistoryCard = ({ item, deleteHistoryAction, navigation }) => {
}}
numberOfLines={1}
>
{`${item.chapterName} - ${moment(
item.lastRead
).calendar()}`}
{`Ch. ${parseChapterNumber(
item.chapterName
)} - ${moment(item.historyTimeRead)
.format("h:mm a")
.toUpperCase()}`}
</Text>
</View>
<View
@@ -66,21 +65,24 @@ const HistoryCard = ({ item, deleteHistoryAction, navigation }) => {
style={{
marginRight: 0,
}}
onPress={() => deleteHistoryAction(item.novelId)}
onPress={() =>
dispatch(deleteHistoryAction(item.historyId))
}
/>
<IconButton
icon="play"
size={24}
color={theme.textColorPrimary}
style={{ marginRight: 10 }}
style={{ marginRight: 0 }}
onPress={() =>
navigation.navigate("ChapterItem", {
navigation.navigate("Chapter", {
chapterId: item.historyChapterId,
chapterUrl: item.chapterUrl,
extensionId: item.extensionId,
sourceId: item.sourceId,
novelUrl: item.novelUrl,
novelName: item.novelName,
novelCover: item.novelCover,
novelId: item.novelId,
chapterName: item.chapterName,
novelName: item.novelName,
})
}
/>
@@ -95,7 +97,7 @@ export default HistoryCard;
const styles = StyleSheet.create({
historyCard: {
marginBottom: 15,
marginVertical: 8,
borderRadius: 4,
flexDirection: "row",
alignItems: "center",
+172
View File
@@ -0,0 +1,172 @@
import React, { useState, useCallback, useRef } from "react";
import {
StyleSheet,
Text,
View,
RefreshControl,
ActivityIndicator,
TouchableOpacity,
} from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { useSelector, useDispatch } from "react-redux";
import { Searchbar } from "../../Components/Searchbar";
import NovelList from "../../Components/NovelList";
import NovelCover from "../../Components/NovelCover";
import EmptyView from "../../Components/EmptyView";
import {
getLibraryAction,
searchLibraryAction,
} from "../../redux/library/library.actions";
import { updateLibraryAction } from "../../redux/updates/updates.actions";
import { useSettings, useTheme } from "../../Hooks/reduxHooks";
import { setNovel } from "../../redux/novel/novel.actions";
import { Portal, TouchableRipple } from "react-native-paper";
import LibraryFilterSheet from "./components/LibraryFilterSheet";
const LibraryScreen = ({ navigation }) => {
const theme = useTheme();
const dispatch = useDispatch();
const { showNumberOfNovels } = useSettings();
const libraryFilterSheetRef = useRef(null);
const {
loading,
novels,
filters: { sort, filter },
} = useSelector((state) => state.libraryReducer);
const [refreshing, setRefreshing] = useState(false);
const [searchText, setSearchText] = useState("");
useFocusEffect(
useCallback(() => {
setSearchText("");
dispatch(getLibraryAction(sort, filter));
}, [getLibraryAction, sort, filter])
);
const onRefresh = () => {
setRefreshing(true);
dispatch(updateLibraryAction());
setRefreshing(false);
};
const clearSearchbar = () => {
dispatch(getLibraryAction(sort, filter));
setSearchText("");
};
const onChangeText = (text) => {
setSearchText(text);
dispatch(searchLibraryAction(text, sort, filter));
};
const renderItem = ({ item }) => (
<NovelCover
item={item}
onPress={() => {
navigation.navigate("Novel", item);
dispatch(setNovel(item));
}}
/>
);
const refreshControl = () => (
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={["white"]}
progressBackgroundColor={theme.colorAccent}
/>
);
// if (!loading) {
// console.log(novels);
// }
const listEmptyComponent = () =>
searchText === "" && (
<EmptyView
icon="Σ(ಠ_ಠ)"
description="Your library is empty. Add series to your library from Browse."
/>
);
return (
<>
<View
style={[
styles.container,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<Searchbar
placeholder={`Search Library ${
showNumberOfNovels ? "(" + novels.length + ")" : ""
}`}
searchText={searchText}
clearSearchbar={clearSearchbar}
onChangeText={onChangeText}
left="magnify"
right="filter-variant"
filter={filter}
onPressRight={() => libraryFilterSheetRef.current.show()}
theme={theme}
/>
{loading ? (
<ActivityIndicator size="small" color={theme.colorAccent} />
) : (
<>
{searchText !== "" && (
<TouchableRipple
onPress={() =>
navigation.navigate("GlobalSearch", {
novelName: searchText,
})
}
borderless
rippleColor={theme.colorAccent}
style={styles.emptySearch}
>
<Text style={{ color: theme.colorAccent }}>
{`Search for "${searchText}" globally`}
</Text>
</TouchableRipple>
)}
<NovelList
data={novels}
renderItem={renderItem}
refreshControl={refreshControl()}
ListEmptyComponent={listEmptyComponent}
/>
<Portal>
<LibraryFilterSheet
bottomSheetRef={libraryFilterSheetRef}
theme={theme}
dispatch={dispatch}
sort={sort}
filter={filter}
/>
</Portal>
</>
)}
</View>
</>
);
};
export default LibraryScreen;
const styles = StyleSheet.create({
container: {
flex: 1,
},
emptySearch: {
alignItems: "center",
paddingVertical: 8,
borderRadius: 4,
},
});
@@ -0,0 +1,362 @@
import React, { useState } from "react";
import {
StyleSheet,
View,
Text,
Animated,
useWindowDimensions,
} from "react-native";
import { TabView, SceneMap, TabBar } from "react-native-tab-view";
import { Checkbox } from "react-native-paper";
import Bottomsheet from "rn-sliding-up-panel";
import { ListItem } from "../../../Components/List";
import { filterLibrary } from "../../../redux/library/library.actions";
import { useSettings } from "../../../Hooks/reduxHooks";
import { setAppSettings } from "../../../redux/settings/settings.actions";
const LibraryFilterSheet = ({
bottomSheetRef,
dispatch,
sort,
filter,
theme,
}) => {
const [animatedValue] = useState(new Animated.Value(0));
const FirstRoute = () => (
<View style={{ flex: 1 }}>
<Checkbox.Item
label="Downloaded"
labelStyle={{
fontSize: 14,
color: theme.textColorPrimary,
}}
color={theme.colorAccent}
uncheckedColor={theme.textColorHint}
status={
filter === "chaptersDownloaded > 0"
? "checked"
: "unchecked"
}
onPress={() =>
filter === "chaptersDownloaded > 0"
? dispatch(filterLibrary(sort, ""))
: dispatch(
filterLibrary(sort, "chaptersDownloaded > 0")
)
}
/>
<Checkbox.Item
label="Unread"
labelStyle={{
fontSize: 14,
color: theme.textColorPrimary,
}}
color={theme.colorAccent}
uncheckedColor={theme.textColorHint}
status={filter === "unread = 1" ? "checked" : "unchecked"}
onPress={() =>
filter === "unread = 1"
? dispatch(filterLibrary(sort, ""))
: dispatch(filterLibrary(sort, "unread = 1"))
}
/>
<Checkbox.Item
label="Completed"
labelStyle={{
fontSize: 14,
color: theme.textColorPrimary,
}}
color={theme.colorAccent}
uncheckedColor={theme.textColorHint}
status={
filter === "chaptersUnread IS NULL"
? "checked"
: "unchecked"
}
onPress={() =>
filter === "chaptersUnread IS NULL"
? dispatch(filterLibrary(sort, ""))
: dispatch(
filterLibrary(sort, "chaptersUnread IS NULL")
)
}
/>
</View>
);
const SecondRoute = () => (
<View style={{ flex: 1 }}>
<ListItem
style={{ paddingVertical: 10 }}
title="Date Added"
titleStyle={{ fontSize: 14 }}
theme={theme}
right={
sort === "novels.novelId ASC"
? "arrow-up"
: sort === "novels.novelId DESC"
? "arrow-down"
: null
}
iconColor={theme.colorAccent}
onPress={() =>
sort === "novels.novelId ASC"
? dispatch(filterLibrary("novels.novelId DESC", filter))
: dispatch(filterLibrary("novels.novelId ASC", filter))
}
/>
<ListItem
style={{ paddingVertical: 10 }}
title="Alphabetically"
titleStyle={{ fontSize: 14 }}
theme={theme}
right={
sort === "novels.novelName ASC"
? "arrow-up"
: sort === "novels.novelName DESC"
? "arrow-down"
: null
}
iconColor={theme.colorAccent}
onPress={() =>
sort === "novels.novelName ASC"
? dispatch(
filterLibrary("novels.novelName DESC", filter)
)
: dispatch(
filterLibrary("novels.novelName ASC", filter)
)
}
/>
<ListItem
style={{ paddingVertical: 10 }}
title="Unread"
titleStyle={{ fontSize: 14 }}
theme={theme}
right={
sort === "novels.unread ASC"
? "arrow-up"
: sort === "novels.unread DESC"
? "arrow-down"
: null
}
iconColor={theme.colorAccent}
onPress={() =>
sort === "novels.unread ASC"
? dispatch(filterLibrary("novels.unread DESC", filter))
: dispatch(filterLibrary("novels.unread ASC", filter))
}
/>
<ListItem
style={{ paddingVertical: 10 }}
title="Downloaded"
titleStyle={{ fontSize: 14 }}
theme={theme}
right={
sort === "chaptersDownloaded ASC"
? "arrow-up"
: sort === "chaptersDownloaded DESC"
? "arrow-down"
: null
}
iconColor={theme.colorAccent}
onPress={() =>
sort === "chaptersDownloaded ASC"
? dispatch(
filterLibrary("chaptersDownloaded DESC", filter)
)
: dispatch(
filterLibrary("chaptersDownloaded ASC", filter)
)
}
/>
<ListItem
style={{ paddingVertical: 10 }}
title="Total Chapters"
titleStyle={{ fontSize: 14 }}
theme={theme}
right={
sort === "chaptersUnread ASC"
? "arrow-up"
: sort === "chaptersUnread DESC"
? "arrow-down"
: null
}
iconColor={theme.colorAccent}
onPress={() =>
sort === "chaptersUnread ASC"
? dispatch(filterLibrary("chaptersUnread DESC", filter))
: dispatch(filterLibrary("chaptersUnread ASC", filter))
}
/>
</View>
);
const displayModes = [
{ displayMode: 0, label: "Compact Grid" },
{ displayMode: 1, label: "Comfortable Grid" },
{ displayMode: 2, label: "List" },
];
const {
displayMode,
showDownloadBadges,
showUnreadBadges,
showNumberOfNovels,
} = useSettings();
const renderCheckboxes = () => {
return displayModes.map((mode) => (
<Checkbox.Item
key={mode.displayMode}
label={mode.label}
labelStyle={{ color: theme.textColorPrimary, fontSize: 14 }}
status={
displayMode === mode.displayMode ? "checked" : "unchecked"
}
// mode="ios"
uncheckedColor={theme.textColorSecondary}
color={theme.colorAccent}
onPress={() =>
dispatch(setAppSettings("displayMode", mode.displayMode))
}
/>
));
};
const ThirdRoute = () => (
<View style={{ flex: 1 }}>
<Text
style={{
color: theme.textColorSecondary,
paddingHorizontal: 16,
paddingVertical: 16,
textTransform: "uppercase",
}}
>
Display Mode
</Text>
{renderCheckboxes()}
<Text
style={{
color: theme.textColorSecondary,
paddingHorizontal: 16,
paddingVertical: 16,
textTransform: "uppercase",
}}
>
Badges
</Text>
<Checkbox.Item
label="Download Badges"
labelStyle={{ color: theme.textColorPrimary, fontSize: 14 }}
status={showDownloadBadges ? "checked" : "unchecked"}
uncheckedColor={theme.textColorSecondary}
color={theme.colorAccent}
onPress={() =>
dispatch(
setAppSettings(
"showDownloadBadges",
!showDownloadBadges
)
)
}
/>
<Checkbox.Item
label="Unread Badges"
labelStyle={{ color: theme.textColorPrimary, fontSize: 14 }}
status={showUnreadBadges ? "checked" : "unchecked"}
uncheckedColor={theme.textColorSecondary}
color={theme.colorAccent}
onPress={() =>
dispatch(
setAppSettings("showUnreadBadges", !showUnreadBadges)
)
}
/>
<Checkbox.Item
label="Show number of items"
labelStyle={{ color: theme.textColorPrimary, fontSize: 14 }}
status={showNumberOfNovels ? "checked" : "unchecked"}
uncheckedColor={theme.textColorSecondary}
color={theme.colorAccent}
onPress={() =>
dispatch(
setAppSettings(
"showNumberOfNovels",
!showNumberOfNovels
)
)
}
/>
</View>
);
const renderScene = SceneMap({
first: FirstRoute,
second: SecondRoute,
third: ThirdRoute,
});
const layout = useWindowDimensions();
const [index, setIndex] = useState(0);
const [routes] = useState([
{ key: "first", title: "Filter" },
{ key: "second", title: "Sort" },
{ key: "third", title: "Display" },
]);
const renderTabBar = (props) => (
<TabBar
{...props}
indicatorStyle={{ backgroundColor: theme.colorAccent }}
style={{ backgroundColor: theme.colorPrimary }}
renderLabel={({ route, focused, color }) => (
<Text style={{ color, margin: 8 }}>{route.title}</Text>
)}
inactiveColor={theme.textColorSecondary}
activeColor={theme.colorAccent}
pressColor={theme.rippleColor}
/>
);
return (
<Bottomsheet
animatedValue={animatedValue}
ref={bottomSheetRef}
draggableRange={{ top: 470, bottom: 0 }}
snappingPoints={[0, 470]}
>
<View
style={[
styles.contentContainer,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<TabView
navigationState={{ index, routes }}
renderTabBar={renderTabBar}
renderScene={renderScene}
onIndexChange={setIndex}
initialLayout={{ width: layout.width }}
style={{ borderTopRightRadius: 8, borderTopLeftRadius: 8 }}
/>
</View>
</Bottomsheet>
);
};
export default LibraryFilterSheet;
const styles = StyleSheet.create({
contentContainer: {
flex: 1,
borderTopRightRadius: 8,
borderTopLeftRadius: 8,
},
});
+72
View File
@@ -0,0 +1,72 @@
import React from "react";
import { View } from "react-native";
import * as Linking from "expo-linking";
import { Appbar } from "../../Components/Appbar";
import { useSelector } from "react-redux";
import { Divider, ListItem, ListSection } from "../../Components/List";
const AboutScreen = ({ navigation }) => {
const theme = useSelector((state) => state.settingsReducer.theme);
return (
<>
<Appbar title="About" onBackAction={() => navigation.goBack()} />
<View style={{ flex: 1, backgroundColor: theme.colorPrimaryDark }}>
<ListSection>
<ListItem
title="Version"
description="Stable 1.0.19"
theme={theme}
/>
<ListItem
title="Build Time"
description="31-05-21 21:30 PM"
theme={theme}
/>
<ListItem
title="What's new"
onPress={() =>
Linking.openURL(
"https://github.com/rajarsheechatterjee/lnreader/commits/main"
)
}
theme={theme}
/>
<Divider theme={theme} />
<ListItem
title="Discord"
description="https://discord.gg/QdcWN4MD63"
onPress={() =>
Linking.openURL("https://discord.gg/QdcWN4MD63")
}
theme={theme}
/>
<ListItem
title="Github"
description="https://github.com/rajarsheechatterjee/lnreader"
onPress={() =>
Linking.openURL(
"https://github.com/rajarsheechatterjee/lnreader"
)
}
theme={theme}
/>
<ListItem
title="Sources"
description="https://github.com/rajarsheechatterjee/lnreader-sources"
onPress={() =>
Linking.openURL(
"https://github.com/rajarsheechatterjee/lnreader-sources"
)
}
theme={theme}
/>
</ListSection>
</View>
</>
);
};
export default AboutScreen;
+129
View File
@@ -0,0 +1,129 @@
import React, { useState } from "react";
import { useDispatch } from "react-redux";
import { Appbar } from "../../Components/Appbar";
import { ScreenContainer } from "../../Components/Common";
import { ListItem, ListSection, ListSubHeader } from "../../Components/List";
import { useTheme } from "../../Hooks/reduxHooks";
import { clearAllHistoryAction } from "../../redux/history/history.actions";
import { deleteNovelCache } from "../../Database/queries/NovelQueries";
import { Button, Dialog, Portal } from "react-native-paper";
const AdvancedSettings = ({ navigation }) => {
const theme = useTheme();
const dispatch = useDispatch();
const [visible, setVisible] = useState(false);
const showDialog = () => setVisible(true);
const hideDialog = () => setVisible(false);
/**
* Confirm Clear Database Dialog
*/
const [clearDatabaseDialog, setClearDatabaseDialog] = useState(false);
const showClearDatabaseDialog = () => setClearDatabaseDialog(true);
const hideClearDatabaseDialog = () => setClearDatabaseDialog(false);
return (
<ScreenContainer theme={theme}>
<Appbar title="Advanced" onBackAction={() => navigation.goBack()} />
<ListSection>
<ListSubHeader theme={theme}>Data</ListSubHeader>
<ListItem
title="Clear database"
description="Delete history for novels not in your library"
onPress={showClearDatabaseDialog}
theme={theme}
/>
<ListItem
title="Clear history"
description="Delete reading history for all novels"
onPress={showDialog}
theme={theme}
/>
</ListSection>
<Portal>
<Dialog
visible={visible}
onDismiss={hideDialog}
style={{
borderRadius: 6,
backgroundColor: theme.colorPrimary,
}}
>
<Dialog.Title
style={{
letterSpacing: 0,
fontSize: 16,
color: theme.textColorPrimary,
}}
>
Are you sure? All history will be lost.
</Dialog.Title>
<Dialog.Actions>
<Button
uppercase={false}
theme={{ colors: { primary: theme.colorAccent } }}
onPress={hideDialog}
>
Cancel
</Button>
<Button
uppercase={false}
theme={{ colors: { primary: theme.colorAccent } }}
onPress={() => {
dispatch(clearAllHistoryAction());
hideDialog();
}}
>
Ok
</Button>
</Dialog.Actions>
</Dialog>
<Dialog
visible={clearDatabaseDialog}
onDismiss={hideClearDatabaseDialog}
style={{
borderRadius: 6,
backgroundColor: theme.colorPrimary,
}}
>
<Dialog.Title
style={{
letterSpacing: 0,
fontSize: 16,
lineHeight: 16 * 1.5,
color: theme.textColorPrimary,
}}
>
Are you sure? Read chapters and progress of non-library
novels will be lost.
</Dialog.Title>
<Dialog.Actions>
<Button
uppercase={false}
theme={{ colors: { primary: theme.colorAccent } }}
onPress={hideClearDatabaseDialog}
>
Cancel
</Button>
<Button
uppercase={false}
theme={{ colors: { primary: theme.colorAccent } }}
onPress={() => {
deleteNovelCache();
hideClearDatabaseDialog();
}}
>
Ok
</Button>
</Dialog.Actions>
</Dialog>
</Portal>
</ScreenContainer>
);
};
export default AdvancedSettings;
+54
View File
@@ -0,0 +1,54 @@
import React from "react";
import { useDispatch } from "react-redux";
import { Appbar } from "../../Components/Appbar";
import {
InfoItem,
ListItem,
ListSection,
ListSubHeader,
} from "../../Components/List";
import { ScreenContainer } from "../../Components/Common";
import { useTheme } from "../../Hooks/reduxHooks";
import { restoreLibraryAction } from "../../redux/library/library.actions";
import { createBackup } from "../../Services/backup";
const BackupSettings = ({ navigation }) => {
const theme = useTheme();
const dispatch = useDispatch();
return (
<>
<ScreenContainer theme={theme}>
<Appbar
title="Backup"
onBackAction={() => navigation.goBack()}
/>
<ListSection>
<ListSubHeader theme={theme}>Backup</ListSubHeader>
<ListItem
title="Create backup"
description="Can be used to restore current library"
onPress={createBackup}
theme={theme}
/>
<ListItem
title="Restore backup"
description="Restore library from backup file"
onPress={() => dispatch(restoreLibraryAction())}
theme={theme}
/>
<InfoItem
title="Create backup may not work on devices with Android 9 or lower."
icon="information-outline"
description="Restore library from backup file"
theme={theme}
/>
</ListSection>
</ScreenContainer>
</>
);
};
export default BackupSettings;
+129
View File
@@ -0,0 +1,129 @@
import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Appbar } from "../../Components/Appbar";
import {
Divider,
ListItem,
ListSection,
ListSubHeader,
} from "../../Components/List";
import { ScreenContainer } from "../../Components/Common";
import { useTheme } from "../../Hooks/reduxHooks";
import DisplayModeModal from "./components/DisplayModeModal";
import GridSizeModal from "./components/GridSizeModal";
import ThemeModal from "./components/ThemeModal";
import AccentColorModal from "./components/AccentColorModal";
const GenralSettings = ({ navigation }) => {
const theme = useTheme();
const dispatch = useDispatch();
const { displayMode, novelsPerRow } = useSelector(
(state) => state.settingsReducer
);
const displayModeLabel = (displayMode) => {
const label = {
0: "Compact Grid",
1: "Comfortable Grid",
2: "List",
};
return label[displayMode] ?? "Compact Grid";
};
/**
* Display Mode Modal
*/
const [displayModalVisible, setDisplayModalVisible] = useState(false);
const showDisplayModal = () => setDisplayModalVisible(true);
const hideDisplayModal = () => setDisplayModalVisible(false);
/**
* Theme Modal
*/
const [themeModalVisible, setthemeModalVisible] = useState(false);
const showthemeModal = () => setthemeModalVisible(true);
const hidethemeModal = () => setthemeModalVisible(false);
/**
* Grid Size Modal
*/
const [gridSizeModalVisible, setGridSizeModalVisible] = useState(false);
const showGridSizeModal = () => setGridSizeModalVisible(true);
const hideGridSizeModal = () => setGridSizeModalVisible(false);
/**
* Accent Color Modal
*/
const [accentColorModal, setAccentColorModal] = useState(false);
const showAccentColorModal = () => setAccentColorModal(true);
const hideAccentColorModal = () => setAccentColorModal(false);
return (
<ScreenContainer theme={theme}>
<Appbar title="General" onBackAction={() => navigation.goBack()} />
<ListSection>
<ListSubHeader theme={theme}>Display</ListSubHeader>
<ListItem
title="Display Mode"
description={displayModeLabel(displayMode)}
onPress={showDisplayModal}
theme={theme}
/>
<ListItem
title="Items per row in library"
description={`${novelsPerRow} items per row`}
onPress={showGridSizeModal}
theme={theme}
/>
<Divider theme={theme} />
<ListSubHeader theme={theme}>Theme</ListSubHeader>
<ListItem
title="Theme"
description={theme.name}
onPress={showthemeModal}
theme={theme}
/>
<ListItem
title="Accent Color"
description={theme.colorAccent}
onPress={showAccentColorModal}
theme={theme}
iconColor={theme.colorAccent}
right="circle"
/>
</ListSection>
<DisplayModeModal
displayMode={displayMode}
displayModalVisible={displayModalVisible}
hideDisplayModal={hideDisplayModal}
dispatch={dispatch}
theme={theme}
/>
<GridSizeModal
dispatch={dispatch}
novelsPerRow={novelsPerRow}
gridSizeModalVisible={gridSizeModalVisible}
hideGridSizeModal={hideGridSizeModal}
theme={theme}
/>
<ThemeModal
dispatch={dispatch}
themeModalVisible={themeModalVisible}
hidethemeModal={hidethemeModal}
theme={theme}
/>
<AccentColorModal
dispatch={dispatch}
accentColorModal={accentColorModal}
hideAccentColorModal={hideAccentColorModal}
theme={theme}
/>
</ScreenContainer>
);
};
export default GenralSettings;
+66
View File
@@ -0,0 +1,66 @@
import React from "react";
import { StyleSheet, View, Image } from "react-native";
import { Appbar } from "../../Components/Appbar";
import { ListItem, ListSection } from "../../Components/List";
import { useTheme } from "../../Hooks/reduxHooks";
const MoreScreen = ({ navigation }) => {
const theme = useTheme();
return (
<>
<Appbar title="More" style={{ elevation: 0 }} />
<View style={{ flex: 1, backgroundColor: theme.colorPrimaryDark }}>
<View style={{ overflow: "hidden", paddingBottom: 4 }}>
<View
style={{
paddingTop: 10,
paddingBottom: 25,
alignItems: "center",
backgroundColor: theme.colorPrimary,
elevation: 4,
}}
>
<Image
source={require("../../../assets/logo.png")}
style={{
height: 80,
width: 80,
tintColor: theme.textColorPrimary,
}}
/>
</View>
</View>
<ListSection>
<ListItem
title="Settings"
icon="cog-outline"
onPress={() =>
navigation.navigate("MoreStack", {
screen: "SettingsStack",
})
}
theme={theme}
/>
<ListItem
title="About"
icon="information-outline"
onPress={() =>
navigation.navigate("MoreStack", {
screen: "About",
})
}
theme={theme}
/>
</ListSection>
</View>
</>
);
};
export default MoreScreen;
const styles = StyleSheet.create({});
+20
View File
@@ -0,0 +1,20 @@
import React from "react";
import { useDispatch } from "react-redux";
import { Appbar } from "../../Components/Appbar";
import { ScreenContainer } from "../../Components/Common";
import { useTheme } from "../../Hooks/reduxHooks";
const ReaderSettings = ({ navigation }) => {
const theme = useTheme();
const dispatch = useDispatch();
return (
<ScreenContainer theme={theme}>
<Appbar title="Reader" onBackAction={() => navigation.goBack()} />
</ScreenContainer>
);
};
export default ReaderSettings;
+91
View File
@@ -0,0 +1,91 @@
import React, { useState } from "react";
import { View } from "react-native";
import { connect, useDispatch } from "react-redux";
import { setAppTheme } from "../../redux/settings/settings.actions";
import {
setAppSettings,
setItemsPerRow,
} from "../../redux/settings/settings.actions";
import { deleteNovelCache } from "../../Database/queries/NovelQueries";
import { Appbar } from "../../Components/Appbar";
import {
Divider,
ListItem,
ListSection,
ListSubHeader,
} from "../../Components/List";
import { useTheme } from "../../Hooks/reduxHooks";
import { clearAllHistoryAction } from "../../redux/history/history.actions";
const SettingsScreen = ({ navigation }) => {
const theme = useTheme();
const dispatch = useDispatch();
return (
<>
<Appbar title="Settings" onBackAction={() => navigation.goBack()} />
<View
style={{
flex: 1,
backgroundColor: theme.colorPrimaryDark,
}}
>
<ListItem
title="General"
icon="tune"
onPress={() =>
navigation.navigate("SettingsStack", {
screen: "GeneralSettings",
})
}
theme={theme}
/>
{/* <ListItem
title="Reader"
icon="book-open-outline"
onPress={() =>
navigation.navigate("SettingsStack", {
screen: "ReaderSettings",
})
}
theme={theme}
/> */}
<ListItem
title="Tracking"
icon="sync"
onPress={() =>
navigation.navigate("SettingsStack", {
screen: "TrackerSettings",
})
}
theme={theme}
/>
<ListItem
title="Backup"
icon="cloud-upload-outline"
onPress={() =>
navigation.navigate("SettingsStack", {
screen: "BackupSettings",
})
}
theme={theme}
/>
<ListItem
title="Advanced"
icon="code-tags"
onPress={() =>
navigation.navigate("SettingsStack", {
screen: "AdvancedSettings",
})
}
theme={theme}
/>
</View>
</>
);
};
export default SettingsScreen;
+158
View File
@@ -0,0 +1,158 @@
import React, { useState } from "react";
import { View } from "react-native";
import { Modal, Portal, Text, Button, Provider } from "react-native-paper";
import * as WebBrowser from "expo-web-browser";
import {
myAnimeListConfig,
getAccessToken,
malTokenWatcher,
} from "../../Services/Trackers/myAnimeList";
import { Appbar } from "../../Components/Appbar";
import { useSelector, useDispatch } from "react-redux";
import {
Divider,
ListItem,
ListSection,
ListSubHeader,
} from "../../Components/List";
import { removeTracker, setTracker } from "../../redux/tracker/tracker.actions";
const TrackerScreen = ({ navigation }) => {
const theme = useSelector((state) => state.settingsReducer.theme);
const tracker = useSelector((state) => state.trackerReducer.tracker);
const dispatch = useDispatch();
// Tracker Modal
const [visible, setVisible] = useState(false);
const showModal = () => setVisible(true);
const hideModal = () => setVisible(false);
return (
<Provider>
<Appbar title="Tacking" onBackAction={() => navigation.goBack()} />
<View
style={{
flex: 1,
backgroundColor: theme.colorPrimaryDark,
paddingVertical: 8,
}}
>
<ListSection>
<ListSubHeader theme={theme}>Services</ListSubHeader>
<ListItem
title="MyAnimeList"
onPress={async () => {
if (tracker) {
showModal();
} else {
await WebBrowser.openAuthSessionAsync(
myAnimeListConfig.authUrl,
myAnimeListConfig.redirectUri
).then((res) => {
console.log(res.type);
if (res.type === "success") {
const { url } = res;
const codeExtractor = new RegExp(
/=([^&]+)/
);
let code = url.match(codeExtractor);
console.log(code);
if (code) {
code = code[1];
getAccessToken(
code,
myAnimeListConfig.codeChallenge
).then((objects) => {
console.log(objects);
dispatch(setTracker(objects));
});
}
}
});
}
}}
right={tracker && "check"}
theme={theme}
/>
{tracker && tracker.expires_in < new Date(Date.now()) && (
<>
<Divider theme={theme} />
<ListSubHeader theme={theme}>
Settings
</ListSubHeader>
<ListItem
title="Revalidate MyAnimeList"
onPress={() => {
const res = malTokenWatcher(tracker);
dispatch(setTracker(res));
}}
theme={theme}
/>
</>
)}
</ListSection>
<Portal>
<Modal
visible={visible}
onDismiss={hideModal}
contentContainerStyle={{
padding: 20,
margin: 20,
borderRadius: 6,
backgroundColor: theme.colorPrimaryDark,
}}
>
<Text
style={{
color: theme.textColorPrimary,
fontSize: 20,
}}
>
Log out from MyAnimeList?
</Text>
<View
style={{
flexDirection: "row",
justifyContent: "flex-end",
}}
>
<Button
style={{ marginTop: 30 }}
labelStyle={{
color: theme.colorAccent,
letterSpacing: 0,
textTransform: "none",
}}
onPress={hideModal}
>
Cancel
</Button>
<Button
style={{ marginTop: 30 }}
labelStyle={{
color: theme.colorAccent,
letterSpacing: 0,
textTransform: "none",
}}
onPress={() => {
dispatch(removeTracker());
hideModal();
}}
>
Logout
</Button>
</View>
</Modal>
</Portal>
</View>
</Provider>
);
};
export default TrackerScreen;
@@ -0,0 +1,80 @@
import React, { useState } from "react";
import { StyleSheet, Text } from "react-native";
import { Modal, Portal, TextInput } from "react-native-paper";
import { setAccentColor } from "../../../redux/settings/settings.actions";
const AccentColorModal = ({
dispatch,
accentColorModal,
hideAccentColorModal,
theme,
}) => {
const [text, setText] = useState(theme.colorAccent);
const [error, setError] = useState();
return (
<Portal>
<Modal
visible={accentColorModal}
onDismiss={() => {
hideAccentColorModal();
setText();
setError();
}}
contentContainerStyle={[
styles.containerStyle,
{ backgroundColor: theme.colorPrimary },
]}
>
<Text
style={[
styles.dialogTitle,
{ color: theme.textColorPrimary },
]}
>
Hex Color Code
</Text>
<TextInput
value={text}
defaultValue={theme.colorAccent}
onChangeText={(text) => setText(text)}
onSubmitEditing={() => {
if (text.match(/^#[0-9a-f]{3,6}$/i)) {
dispatch(setAccentColor(text));
hideAccentColorModal();
} else {
setError("Enter a valid hex color code");
}
}}
mode="outlined"
theme={{
colors: {
primary: theme.colorAccent,
placeholder: theme.textColorHint,
text: theme.textColorPrimary,
background: "transparent",
},
}}
underlineColor={theme.textColorHint}
dense
error={error}
/>
<Text style={{ color: "#FF0033", paddingTop: 8 }}>{error}</Text>
</Modal>
</Portal>
);
};
export default AccentColorModal;
const styles = StyleSheet.create({
containerStyle: {
margin: 30,
padding: 20,
borderRadius: 8,
},
dialogTitle: {
fontSize: 18,
marginBottom: 16,
},
});
@@ -0,0 +1,62 @@
import React from "react";
import { StyleSheet } from "react-native";
import { Portal, Modal, Checkbox } from "react-native-paper";
import { setAppSettings } from "../../../redux/settings/settings.actions";
const DisplayModeModal = ({
theme,
dispatch,
displayMode,
hideDisplayModal,
displayModalVisible,
}) => {
const displayModes = [
{ displayMode: 0, label: "Compact Grid" },
{ displayMode: 1, label: "Comfortable Grid" },
{ displayMode: 2, label: "List" },
];
const renderCheckboxes = () => {
return displayModes.map((mode) => (
<Checkbox.Item
key={mode.displayMode}
label={mode.label}
labelStyle={{ color: theme.textColorPrimary }}
status={
displayMode === mode.displayMode ? "checked" : "unchecked"
}
mode="ios"
uncheckedColor={theme.textColorSecondary}
color={theme.colorAccent}
onPress={() =>
dispatch(setAppSettings("displayMode", mode.displayMode))
}
/>
));
};
return (
<Portal>
<Modal
visible={displayModalVisible}
onDismiss={hideDisplayModal}
contentContainerStyle={[
styles.containerStyle,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
{renderCheckboxes()}
</Modal>
</Portal>
);
};
export default DisplayModeModal;
const styles = StyleSheet.create({
containerStyle: {
padding: 20,
margin: 20,
borderRadius: 6,
},
});
@@ -0,0 +1,97 @@
import React from "react";
import { Text, StyleSheet } from "react-native";
import { Portal, Modal } from "react-native-paper";
import Slider from "@react-native-community/slider";
import { RadioButton, RadioButtonGroup } from "../../../Components/RadioButton";
import { setNovelsPerRow } from "../../../redux/settings/settings.actions";
const GridSizeModal = ({
dispatch,
novelsPerRow,
gridSizeModalVisible,
hideGridSizeModal,
theme,
}) => {
const gridSizes = {
5: "XS",
4: "S",
3: "M",
2: "L",
1: "XL",
};
return (
<Portal>
<Modal
visible={gridSizeModalVisible}
onDismiss={hideGridSizeModal}
contentContainerStyle={[
styles.containerStyle,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<Text
style={[
styles.modalHeader,
{ color: theme.textColorPrimary },
]}
>
Grid size
</Text>
<Text
style={[
styles.modalDescription,
{ color: theme.textColorSecondary },
]}
>
{`${novelsPerRow} per row`}
</Text>
{/* <Slider
style={styles.slider}
value={novelsPerRow}
minimumValue={1}
maximumValue={5}
step={1}
minimumTrackTintColor={theme.colorAccent}
maximumTrackTintColor="#000000"
thumbTintColor={theme.colorAccent}
onValueChange={(value) => dispatch(setNovelsPerRow(value))}
/> */}
<RadioButtonGroup
onValueChange={(value) => dispatch(setNovelsPerRow(value))}
value={novelsPerRow}
>
{Object.keys(gridSizes).map((item) => (
<RadioButton
key={item}
value={item}
label={gridSizes[item]}
theme={theme}
/>
))}
</RadioButtonGroup>
</Modal>
</Portal>
);
};
export default GridSizeModal;
const styles = StyleSheet.create({
containerStyle: {
padding: 20,
margin: 20,
borderRadius: 6,
},
modalHeader: {
fontSize: 18,
marginBottom: 10,
},
modalDescription: {
marginBottom: 16,
},
slider: {
width: "100%",
height: 40,
},
});
+58
View File
@@ -0,0 +1,58 @@
import React from "react";
import { StyleSheet } from "react-native";
import { Portal, Modal, Checkbox } from "react-native-paper";
import { setStatusBarStyle } from "expo-status-bar";
import { setAppTheme } from "../../../redux/settings/settings.actions";
const ThemeModal = ({ themeModalVisible, hidethemeModal, dispatch, theme }) => {
const themes = [
{ id: 0, name: "AMOLED Dark", statusBar: "light" },
{ id: 1, name: "Light", statusBar: "dark" },
{ id: 2, name: "Dark", statusBar: "light" },
{ id: 3, name: "Midnight Dusk", statusBar: "light" },
{ id: 4, name: "Lime", statusBar: "light" },
{ id: 5, name: "Iris Blue", statusBar: "light" },
];
const renderThemeCheckboxes = () =>
themes.map((item) => (
<Checkbox.Item
key={item.id}
label={item.name}
labelStyle={{ color: theme.textColorPrimary }}
status={theme.id === item.id ? "checked" : "unchecked"}
mode="ios"
color={theme.colorAccent}
onPress={() => {
dispatch(setAppTheme(item.id));
setStatusBarStyle(item.statusBar);
}}
/>
));
return (
<Portal>
<Modal
visible={themeModalVisible}
onDismiss={hidethemeModal}
contentContainerStyle={[
styles.containerStyle,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
{renderThemeCheckboxes()}
</Modal>
</Portal>
);
};
export default ThemeModal;
const styles = StyleSheet.create({
containerStyle: {
padding: 20,
margin: 20,
borderRadius: 6,
},
});
+386
View File
@@ -0,0 +1,386 @@
import React, { useEffect, useRef, useState } from "react";
import {
StyleSheet,
View,
FlatList,
RefreshControl,
StatusBar,
Dimensions,
Share,
} from "react-native";
import { Provider, Portal, Appbar, IconButton, Menu } from "react-native-paper";
import { useDispatch } from "react-redux";
import {
bookmarkChapterAction,
deleteAllChaptersAction,
downloadAllChaptersAction,
getNovelAction,
markChapterReadAction,
markChaptersRead,
markChapterUnreadAction,
markPreviousChaptersReadAction,
sortAndFilterChapters,
updateNovelAction,
} from "../../redux/novel/novel.actions";
import {
useContinueReading,
useNovel,
usePreferences,
useTheme,
} from "../../Hooks/reduxHooks";
import { showToast } from "../../Hooks/showToast";
import ChapterItem from "./components/ChapterItem";
import NovelInfoHeader from "./components/NovelHeader";
import ChaptersSettingsSheet from "./components/ChaptersSettingsSheet";
import TrackSheet from "./components/Tracker/TrackSheet";
import ChapterActionsSheet from "./components/ChapterActionsSheet";
import { Row } from "../../Components/Common";
const Novel = ({ route, navigation }) => {
const item = route.params;
const { sourceId, novelUrl, novelName, followed, novelId } = item;
const theme = useTheme();
const dispatch = useDispatch();
const { novel, chapters, loading, updating, downloading } = useNovel();
const [selected, setSelected] = useState([]);
const [downloadMenu, showDownloadMenu] = useState(false);
let chaptersSettingsSheetRef = useRef(null);
let trackerSheetRef = useRef(null);
let chapterActionsSheetRef = useRef(null);
const {
sort = "ORDER BY chapterId ASC",
filter = "",
showChapterTitles = false,
} = usePreferences(novel.novelId);
let { lastReadChapter, position } = useContinueReading(
chapters,
novel.novelId
);
useEffect(() => {
dispatch(
getNovelAction(followed, sourceId, novelUrl, novelId, sort, filter)
);
}, [getNovelAction]);
const onRefresh = () => {
dispatch(updateNovelAction(sourceId, novelUrl, novelId, sort, filter));
showToast(`Updated ${novelName}`);
};
const refreshControl = () => (
<RefreshControl
onRefresh={onRefresh}
refreshing={updating}
colors={[theme.textColorPrimary]}
progressBackgroundColor={theme.colorPrimary}
/>
);
const renderItem = ({ item, index }) => (
<ChapterItem
theme={theme}
chapter={item}
index={index}
novelUrl={novelUrl}
dispatch={dispatch}
sourceId={sourceId}
navigation={navigation}
lastReadChapter={lastReadChapter}
position={position}
downloading={downloading}
selected={selected}
novelName={novelName}
selected={selected}
setSelected={setSelected}
showChapterTitles={showChapterTitles}
chapterActionsSheetRef={chapterActionsSheetRef}
/>
);
return (
<Provider>
<View
style={[
styles.container,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
{selected.length > 0 ? (
<View
style={{
backgroundColor: theme.colorPrimary,
position: "absolute",
zIndex: 1,
width: Dimensions.get("window").width,
flexDirection: "row",
alignItems: "center",
paddingTop: StatusBar.currentHeight,
paddingBottom: 8,
}}
>
<Appbar.Action
icon="close"
color={theme.textColorPrimary}
onPress={() => {
setSelected([]);
}}
/>
<Appbar.Content
title={selected.length}
titleStyle={{ color: theme.textColorPrimary }}
/>
<Appbar.Action
icon="bookmark-outline"
color={theme.textColorPrimary}
onPress={() => {
dispatch(bookmarkChapterAction(selected));
setSelected([]);
}}
/>
{selected.some((obj) => obj.read === 0) && (
<Appbar.Action
icon="check"
color={theme.textColorPrimary}
onPress={() => {
dispatch(
markChaptersRead(
selected,
novel.novelId
)
);
setSelected([]);
}}
/>
)}
{selected.some((obj) => obj.read === 1) && (
<Appbar.Action
icon="check-outline"
color={theme.textColorPrimary}
onPress={() => {
dispatch(
markChapterUnreadAction(
selected,
novel.novelId
)
);
setSelected([]);
}}
/>
)}
{selected.length === 1 && (
<Appbar.Action
icon="playlist-check"
color={theme.textColorPrimary}
onPress={() => {
dispatch(
markPreviousChaptersReadAction(
selected[0].chapterId,
selected[0].novelId
)
);
setSelected([]);
}}
/>
)}
{selected.some((obj) => obj.downloaded === 1) && (
<Appbar.Action
icon="trash-can-outline"
color={theme.textColorPrimary}
onPress={() => {
dispatch(deleteAllChaptersAction(selected));
setSelected([]);
}}
/>
)}
{selected.some((obj) => obj.downloaded === 0) && (
<Appbar.Action
icon="download-outline"
color={theme.textColorPrimary}
onPress={() => {
dispatch(
downloadAllChaptersAction(
novel.sourceId,
novel.novelUrl,
selected
)
);
setSelected([]);
}}
/>
)}
<Appbar.Action
icon="select-all"
color={theme.textColorPrimary}
onPress={() => {
setSelected(chapters);
}}
/>
</View>
) : (
<View
style={{
position: "absolute",
zIndex: 1,
height: StatusBar.currentHeight + 54,
width: "100%",
flexDirection: "row",
justifyContent: "space-between",
}}
>
<IconButton
icon="arrow-left"
color="white"
size={24}
style={{ marginTop: StatusBar.currentHeight + 8 }}
onPress={() => navigation.goBack()}
/>
<Row>
<Menu
visible={downloadMenu}
onDismiss={() => showDownloadMenu(false)}
anchor={
<IconButton
icon="download"
color="white"
size={24}
style={{
marginTop:
StatusBar.currentHeight + 8,
}}
onPress={() => showDownloadMenu(true)}
/>
}
contentStyle={{
backgroundColor: theme.menuColor,
}}
>
<Menu.Item
title="Download all"
style={{ backgroundColor: theme.menuColor }}
titleStyle={{
color: theme.textColorPrimary,
}}
onPress={() =>
dispatch(
downloadAllChaptersAction(
novel.sourceId,
novel.novelUrl,
chapters
)
)
}
/>
<Menu.Item
title="Delete downloads"
style={{ backgroundColor: theme.menuColor }}
titleStyle={{
color: theme.textColorPrimary,
}}
onPress={() =>
dispatch(
deleteAllChaptersAction(chapters)
)
}
/>
</Menu>
<IconButton
icon="share-variant"
color="white"
size={21}
style={{
marginTop: StatusBar.currentHeight + 8,
marginRight: 16,
}}
onPress={() =>
Share.share({
message: novel.sourceUrl,
})
}
/>
</Row>
</View>
)}
<FlatList
data={!loading && chapters}
keyExtractor={(item) => item.chapterId.toString()}
removeClippedSubviews={true}
maxToRenderPerBatch={5}
windowSize={15}
initialNumToRender={7}
renderItem={renderItem}
ListHeaderComponent={
<NovelInfoHeader
item={item}
novel={novel}
theme={theme}
filter={filter}
loading={loading}
lastRead={lastReadChapter}
dispatch={dispatch}
chapters={chapters}
navigation={navigation}
trackerSheetRef={trackerSheetRef}
chaptersSettingsSheetRef={chaptersSettingsSheetRef}
/>
}
refreshControl={refreshControl()}
/>
{!loading && (
<Portal>
<ChaptersSettingsSheet
novelUrl={novelUrl}
bottomSheetRef={chaptersSettingsSheetRef}
dispatch={dispatch}
sortAndFilterChapters={sortAndFilterChapters}
novelId={novel.novelId}
sort={sort}
theme={theme}
filter={filter}
showChapterTitles={showChapterTitles}
/>
<TrackSheet
bottomSheetRef={trackerSheetRef}
novelId={novel.novelId}
novelName={novel.novelName}
theme={theme}
/>
{/* <ChapterActionsSheet
theme={theme}
selected={selected}
chapters={chapters}
dispatch={dispatch}
setSelected={setSelected}
bottomSheetRef={chapterActionsSheetRef}
markChapterReadAction={markChapterReadAction}
markChapterUnreadAction={markChapterUnreadAction}
bookmarkChapterAction={bookmarkChapterAction}
/> */}
</Portal>
)}
</View>
</Provider>
);
};
export default Novel;
const styles = StyleSheet.create({
container: { flex: 1 },
rowBack: {
alignItems: "center",
flex: 1,
flexDirection: "row",
justifyContent: "space-between",
},
});
@@ -0,0 +1,176 @@
import React, { useState } from "react";
import { StyleSheet, View, Text, Animated } from "react-native";
import { List } from "react-native-paper";
import Bottomsheet from "rn-sliding-up-panel";
import BottomSheetHandle from "../../../Components/BottomSheetHandle";
import { ListItem } from "../../../Components/List";
import {
markPreviousChaptersReadAction,
markPreviousChaptersUnreadAction,
} from "../../../redux/novel/novel.actions";
const ChapterActionsSheet = ({
bottomSheetRef,
selected,
theme,
markChapterReadAction,
markChapterUnreadAction,
dispatch,
bookmarkChapterAction,
setSelected,
}) => {
const [animatedValue] = useState(new Animated.Value(0));
return (
<Bottomsheet
animatedValue={animatedValue}
ref={bottomSheetRef}
draggableRange={{ top: 270, bottom: 0 }}
snappingPoints={[0, 270]}
>
<View
style={[
styles.contentContainer,
{ backgroundColor: theme.colorPrimary },
]}
>
<Text
style={{
color: theme.textColorPrimary,
textAlign: "center",
paddingTop: 16,
paddingBottom: 8,
fontSize: 16,
}}
numberOfLines={1}
>
{selected?.chapterName}
</Text>
<List.Item
title="Mark previous chapters as read"
titleStyle={{ color: theme.textColorPrimary, fontSize: 15 }}
left={() => (
<List.Icon
color={theme.textColorPrimary}
icon="eye-check"
style={{ marginVertical: 0 }}
/>
)}
onPress={() => {
dispatch(
markPreviousChaptersReadAction(
selected?.chapterId,
selected?.novelId
)
);
// bottomSheetRef.current.hide();
}}
rippleColor={theme.rippleColor}
/>
<List.Item
title="Mark previous chapters as unread"
titleStyle={{ color: theme.textColorPrimary, fontSize: 15 }}
left={() => (
<List.Icon
color={theme.textColorPrimary}
icon="eye-off"
style={{ marginVertical: 0 }}
/>
)}
onPress={() => {
dispatch(
markPreviousChaptersUnreadAction(
selected?.chapterId,
selected?.novelId
)
);
// bottomSheetRef.current.hide();
}}
rippleColor={theme.rippleColor}
/>
<List.Item
title={
selected?.read
? "Mark chapter as unread"
: "Mark chapter as read"
}
titleStyle={{ color: theme.textColorPrimary, fontSize: 15 }}
left={() => (
<List.Icon
color={theme.textColorPrimary}
icon={selected?.read ? "eye-off" : "eye"}
style={{ marginVertical: 0 }}
/>
)}
onPress={() => {
if (selected?.read) {
dispatch(
markChapterUnreadAction(selected?.chapterId)
);
setSelected((selected) => ({
...selected,
read: 0,
}));
} else {
dispatch(
markChapterReadAction(
selected?.chapterId,
selected?.novelId
)
);
setSelected((selected) => ({
...selected,
read: 1,
}));
}
// bottomSheetRef.current.hide();
}}
rippleColor={theme.rippleColor}
/>
<List.Item
title={
selected?.bookmark
? "Remove bookmark"
: "Bookmark chapter"
}
titleStyle={{ color: theme.textColorPrimary, fontSize: 15 }}
left={() => (
<List.Icon
color={theme.textColorPrimary}
icon={
selected?.bookmark ? "bookmark-off" : "bookmark"
}
style={{ marginVertical: 0 }}
/>
)}
onPress={() => {
dispatch(
bookmarkChapterAction(
selected?.bookmark,
selected?.chapterId
)
);
setSelected((selected) => ({
...selected,
bookmark: !selected.bookmark,
}));
// bottomSheetRef.current.hide();
}}
rippleColor={theme.rippleColor}
/>
</View>
</Bottomsheet>
);
};
export default ChapterActionsSheet;
const styles = StyleSheet.create({
contentContainer: {
flex: 1,
borderTopRightRadius: 8,
borderTopLeftRadius: 8,
},
});
+258
View File
@@ -0,0 +1,258 @@
import React, { useState } from "react";
import { View, Text, ActivityIndicator, StyleSheet } from "react-native";
import { TouchableRipple, IconButton, Menu } from "react-native-paper";
import {
deleteChapterAction,
downloadChapterAction,
} from "../../../redux/novel/novel.actions";
import { parseChapterNumber } from "../../../Services/utils/helpers";
const ChapterItem = ({
novelUrl,
novelName,
chapter,
sourceId,
dispatch,
theme,
navigation,
downloading,
index,
position,
selected,
setSelected,
showChapterTitles,
chapterActionsSheetRef,
}) => {
const [deleteChapterMenu, setDeleteChapterMenu] = useState(false);
const showDeleteChapterMenu = () => setDeleteChapterMenu(true);
const hideDeleteChapterMenu = () => setDeleteChapterMenu(false);
const navigateToChapter = () =>
navigation.navigate("Chapter", {
sourceId,
novelUrl,
novelId: chapter.novelId,
chapterId: chapter.chapterId,
chapterUrl: chapter.chapterUrl,
chapterName: chapter.chapterName,
novelName: novelName,
});
const displayDownloadButton = () => {
if (downloading.indexOf(chapter.chapterId) !== -1) {
return (
<ActivityIndicator
color={theme.textColorHint}
size={25}
style={{ margin: 3.5, padding: 5 }}
/>
);
} else if (chapter.downloaded === 1) {
return (
<Menu
visible={deleteChapterMenu}
onDismiss={hideDeleteChapterMenu}
anchor={
<IconButton
icon="check-circle"
animated
color={theme.textColorPrimary}
size={25}
onPress={showDeleteChapterMenu}
style={{ margin: 2 }}
/>
}
contentStyle={{ backgroundColor: theme.menuColor }}
>
<Menu.Item
onPress={() =>
dispatch(
deleteChapterAction(
chapter.chapterId,
chapter.chapterName
)
)
}
title="Delete"
titleStyle={{ color: theme.textColorPrimary }}
/>
</Menu>
);
} else {
return (
<IconButton
icon="arrow-down-circle-outline"
animated
color={theme.textColorHint}
size={25}
onPress={() =>
dispatch(
downloadChapterAction(
sourceId,
novelUrl,
chapter.chapterUrl,
chapter.chapterName,
chapter.chapterId
)
)
}
style={{ margin: 2 }}
/>
);
}
};
const bookmarkButton = () => {
if (chapter.bookmark) {
return (
<IconButton
icon="bookmark"
color={theme.colorAccent}
size={18}
style={{ marginLeft: 2 }}
/>
);
}
};
const isSelected = (chapterId) => {
return selected.some((obj) => obj.chapterId === chapterId);
};
return (
<TouchableRipple
style={[
styles.chapterCardContainer,
isSelected(chapter.chapterId) && {
backgroundColor: theme.rippleColor,
},
]}
onPress={() => {
if (selected.length === 0) {
navigateToChapter();
} else {
if (isSelected(chapter.chapterId)) {
setSelected((selected) =>
selected.filter(
(item) => item.chapterId !== chapter.chapterId
)
);
} else {
setSelected((selected) => [...selected, chapter]);
}
}
}}
onLongPress={() => {
if (isSelected(chapter.chapterId)) {
setSelected((selected) =>
selected.filter(
(item) => item.chapterId !== chapter.chapterId
)
);
} else {
setSelected((selected) => [...selected, chapter]);
}
// chapterActionsSheetRef.current.show();
}}
rippleColor={theme.rippleColor}
>
<>
<View>
<View
style={{
flexDirection: "row",
alignItems: "center",
}}
>
{bookmarkButton()}
<View>
<Text
style={[
{ color: theme.textColorPrimary },
chapter.read && {
color: theme.textColorHint,
},
]}
numberOfLines={1}
>
{showChapterTitles
? parseChapterNumber(chapter.chapterName)
? "Chapter " +
parseChapterNumber(
chapter.chapterName
)
: "Chapter " + index
: chapter.chapterName.substring(0, 50)}
</Text>
<View
style={{
flexDirection: "row",
marginTop: 5,
}}
>
{chapter.releaseDate && (
<Text
style={[
{
color: theme.textColorSecondary,
fontSize: 12,
},
chapter.read && {
color: theme.textColorHint,
},
]}
numberOfLines={1}
>
{chapter.releaseDate}
</Text>
)}
{position &&
!chapter.read &&
position[chapter.chapterId] &&
position[chapter.chapterId].percentage <
100 &&
position[chapter.chapterId].percentage >
0 && (
<Text
style={[
{
color: theme.textColorHint,
fontSize: 12,
marginLeft: 0,
},
chapter.releaseDate && {
marginLeft: 5,
},
]}
numberOfLines={1}
>
{chapter.releaseDate && "• "}
{"Progress " +
position[chapter.chapterId]
.percentage +
"%"}
</Text>
)}
</View>
</View>
</View>
</View>
<View>{displayDownloadButton()}</View>
</>
</TouchableRipple>
);
};
export default ChapterItem;
const styles = StyleSheet.create({
chapterCardContainer: {
paddingHorizontal: 15,
paddingVertical: 7,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
});
@@ -0,0 +1,213 @@
import React, { useState } from "react";
import {
StyleSheet,
View,
Text,
Animated,
useWindowDimensions,
} from "react-native";
import { TabView, SceneMap, TabBar } from "react-native-tab-view";
import Bottomsheet from "rn-sliding-up-panel";
import BottomSheetHandle from "../../../Components/BottomSheetHandle";
import { showChapterTitlesAction } from "../../../redux/novel/novel.actions";
import { ListItem } from "../../../Components/List";
import { RadioButton, RadioButtonGroup } from "../../../Components/RadioButton";
import { Checkbox } from "react-native-paper";
const ChaptersSettingsSheet = ({
bottomSheetRef,
novelId,
sortAndFilterChapters,
dispatch,
sort,
filter,
theme,
showChapterTitles,
}) => {
const sortChapters = (val) =>
dispatch(sortAndFilterChapters(novelId, val, filter));
const filterChapters = (val) =>
dispatch(sortAndFilterChapters(novelId, sort, val));
const [animatedValue] = useState(new Animated.Value(0));
const FirstRoute = () => (
<View style={{ flex: 1 }}>
<Checkbox.Item
label="Downloaded"
labelStyle={{
fontSize: 14,
color: theme.textColorPrimary,
}}
color={theme.colorAccent}
uncheckedColor={theme.textColorHint}
status={
filter.match("AND downloaded=1") ? "checked" : "unchecked"
}
onPress={() =>
filter.match("AND downloaded=1")
? filterChapters(
filter.replace(" AND downloaded=1", "")
)
: filterChapters(filter + " AND downloaded=1")
}
/>
<Checkbox.Item
label="Unread"
labelStyle={{
fontSize: 14,
color: theme.textColorPrimary,
}}
color={theme.colorAccent}
uncheckedColor={theme.textColorHint}
status={
filter.match("AND `read`=0")
? "checked"
: filter.match("AND `read`=1")
? "indeterminate"
: "unchecked"
}
onPress={() => {
if (filter.match("AND `read`=0")) {
filterChapters(
filter.replace(" AND `read`=0", " AND `read`=1")
);
} else if (filter.match("AND `read`=1")) {
filterChapters(filter.replace(" AND `read`=1", ""));
} else {
filterChapters(filter + " AND `read`=0");
}
}}
/>
<Checkbox.Item
label="Bookmarked"
labelStyle={{
fontSize: 14,
color: theme.textColorPrimary,
}}
color={theme.colorAccent}
uncheckedColor={theme.textColorHint}
status={
filter.match("AND bookmark=1") ? "checked" : "unchecked"
}
onPress={() => {
filter.match("AND bookmark=1")
? filterChapters(filter.replace(" AND bookmark=1", ""))
: filterChapters(filter + " AND bookmark=1");
}}
/>
</View>
);
const SecondRoute = () => (
<View style={{ flex: 1 }}>
<ListItem
style={{ paddingVertical: 10 }}
title="By source"
titleStyle={{ fontSize: 14 }}
theme={theme}
right={
sort === "ORDER BY chapterId ASC"
? "arrow-up"
: "arrow-down"
}
iconColor={theme.colorAccent}
onPress={() =>
sort === "ORDER BY chapterId ASC"
? sortChapters("ORDER BY chapterId DESC")
: sortChapters("ORDER BY chapterId ASC")
}
/>
</View>
);
const ThirdRoute = () => (
<View style={{ flex: 1, padding: 8 }}>
<RadioButtonGroup
onValueChange={(value) =>
dispatch(showChapterTitlesAction(novelId, value))
}
value={showChapterTitles}
>
<RadioButton
value={false}
label="Source title"
theme={theme}
labelStyle={{ fontSize: 14 }}
/>
<RadioButton
value={true}
label="Chapter number"
theme={theme}
labelStyle={{ fontSize: 14 }}
/>
</RadioButtonGroup>
</View>
);
const renderScene = SceneMap({
first: FirstRoute,
second: SecondRoute,
third: ThirdRoute,
});
const layout = useWindowDimensions();
const [index, setIndex] = useState(0);
const [routes] = useState([
{ key: "first", title: "Filter" },
{ key: "second", title: "Sort" },
{ key: "third", title: "Display" },
]);
const renderTabBar = (props) => (
<TabBar
{...props}
indicatorStyle={{ backgroundColor: theme.colorAccent }}
style={{ backgroundColor: theme.colorPrimary }}
renderLabel={({ route, focused, color }) => (
<Text style={{ color, margin: 8 }}>{route.title}</Text>
)}
inactiveColor={theme.textColorSecondary}
activeColor={theme.colorAccent}
pressColor={theme.rippleColor}
/>
);
return (
<Bottomsheet
animatedValue={animatedValue}
ref={bottomSheetRef}
draggableRange={{ top: 220, bottom: 0 }}
snappingPoints={[0, 220]}
>
<View
style={[
styles.contentContainer,
{ backgroundColor: theme.colorPrimary },
]}
>
<TabView
navigationState={{ index, routes }}
renderTabBar={renderTabBar}
renderScene={renderScene}
onIndexChange={setIndex}
initialLayout={{ width: layout.width }}
style={{ borderTopRightRadius: 8, borderTopLeftRadius: 8 }}
/>
</View>
</Bottomsheet>
);
};
export default ChaptersSettingsSheet;
const styles = StyleSheet.create({
contentContainer: {
flex: 1,
borderTopRightRadius: 8,
borderTopLeftRadius: 8,
},
});
@@ -0,0 +1,30 @@
import React from "react";
import { Text, StyleSheet } from "react-native";
export const GenreChip = ({ children, theme }) => (
<Text
style={[
styles.genreChip,
{
color: theme.colorAccent,
borderColor: theme.colorAccent,
backgroundColor: theme.colorPrimary,
},
]}
>
{children}
</Text>
);
const styles = StyleSheet.create({
genreChip: {
flex: 1,
justifyContent: "center",
paddingVertical: 2,
paddingHorizontal: 10,
marginHorizontal: 2,
fontSize: 13,
borderWidth: 1,
borderRadius: 50,
},
});
@@ -0,0 +1,216 @@
import React from "react";
import {
FlatList,
Image,
ImageBackground,
StyleSheet,
Text,
View,
} from "react-native";
import { Chip, IconButton } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import easeGradient from "react-native-easing-gradient";
const NovelInfoContainer = ({ children }) => (
<View style={styles.novelInfoContainer}>{children}</View>
);
const CoverImage = ({ children, source, theme }) => {
const { colors, locations } = easeGradient({
colorStops: {
0: { color: "rgba(0,0,0,0.2)" },
1: { color: theme.colorPrimaryDark },
},
});
return (
<ImageBackground source={source} style={styles.coverImage}>
<LinearGradient
colors={colors}
locations={locations}
style={styles.linearGradient}
>
{children}
</LinearGradient>
</ImageBackground>
);
};
const NovelThumbnail = ({ source }) => (
<Image source={source} style={styles.novelThumbnail} />
);
const NovelTitle = ({ theme, children, onLongPress, onPress }) => (
<Text
onLongPress={onLongPress}
onPress={onPress}
numberOfLines={2}
style={[styles.novelTitle, { color: theme.textColorPrimary }]}
>
{children}
</Text>
);
const NovelAuthor = ({ theme, children }) => (
<Text
style={[styles.novelAuthor, { color: theme.textColorSecondary }]}
numberOfLines={2}
>
{children}
</Text>
);
const NovelInfo = ({ theme, children }) => (
<Text
style={[styles.novelInfo, { color: theme.textColorSecondary }]}
numberOfLines={1}
>
{children}
</Text>
);
const FollowButton = ({ theme, onPress, followed }) => (
<View style={{ alignItems: "center", flex: 1 }}>
<IconButton
icon={followed ? "heart" : "heart-outline"}
color={followed ? theme.colorAccent : theme.textColorSecondary}
size={24}
style={{ margin: 0 }}
onPress={onPress}
/>
<Text
style={{
fontSize: 12,
color: followed ? theme.colorAccent : theme.textColorSecondary,
}}
>
{followed ? "In Library" : "Add to library"}
</Text>
</View>
);
const TrackerButton = ({ theme, isTracked, onPress }) => (
<View style={{ alignItems: "center", flex: 1 }}>
<IconButton
icon={isTracked ? "check" : "sync"}
color={isTracked ? theme.colorAccent : theme.textColorSecondary}
size={24}
style={{ margin: 0 }}
onPress={onPress}
/>
<Text
style={{
fontSize: 12,
color: isTracked ? theme.colorAccent : theme.textColorSecondary,
}}
>
{isTracked ? "Tracked" : "Tracking"}
</Text>
</View>
);
const GenreChip = ({ children, theme }) => (
<Text
style={[
styles.genreChip,
{
color: theme.colorAccent,
borderColor: theme.colorAccent,
backgroundColor: theme.colorPrimary,
},
]}
>
{children}
</Text>
);
const NovelGenres = ({ theme, genre }) => {
const data = genre.split(",");
const renderItem = ({ item }) => (
<GenreChip theme={theme}>{item}</GenreChip>
);
return (
<FlatList
contentContainerStyle={styles.novelGenres}
horizontal
data={data}
keyExtractor={(item) => item}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
/>
);
};
export {
NovelInfoContainer,
CoverImage,
NovelThumbnail,
NovelTitle,
NovelAuthor,
NovelInfo,
FollowButton,
TrackerButton,
NovelGenres,
};
const styles = StyleSheet.create({
novelInfoContainer: {
flex: 1,
flexDirection: "row",
margin: 16,
paddingTop: 90,
},
coverImage: {
height: 285,
},
linearGradient: {
height: 286,
},
novelThumbnail: {
height: 160,
width: 110,
margin: 3.2,
borderRadius: 6,
},
novelTitle: {
fontWeight: "bold",
fontSize: 18,
},
novelAuthor: {
marginVertical: 3,
fontSize: 14,
fontWeight: "bold",
},
novelInfo: {
marginVertical: 3,
fontSize: 14,
},
followButton: {
height: 32,
justifyContent: "center",
alignItems: "center",
marginLeft: 16,
paddingLeft: 4,
borderWidth: 0,
elevation: 0,
},
novelGenres: {
// marginTop: 12,
paddingHorizontal: 16,
paddingBottom: 16,
},
genreChip: {
flex: 1,
justifyContent: "center",
paddingVertical: 2,
paddingHorizontal: 10,
marginHorizontal: 2,
fontSize: 13,
borderWidth: 1,
borderRadius: 50,
},
});
@@ -0,0 +1,58 @@
import React, { useState } from "react";
import { View, Text, StyleSheet } from "react-native";
const NovelSummary = ({ summary, followed, theme }) => {
const [more, showMore] = useState(!followed);
return (
summary && (
<View style={styles.summaryContainer}>
<Text
style={{
color: theme.textColorSecondary,
lineHeight: 20,
}}
numberOfLines={more ? 200 : 2}
onPress={() => showMore(!more)}
ellipsizeMode="clip"
>
{summary}
</Text>
<Text
style={[
{
color: theme.colorAccent,
backgroundColor: theme.colorPrimaryDark,
},
styles.showMoreButton,
]}
onPress={() => showMore(!more)}
>
{more ? "Less" : "More"}
</Text>
</View>
)
);
};
export default NovelSummary;
const styles = StyleSheet.create({
summaryContainer: {
paddingHorizontal: 16,
marginVertical: 16,
},
// summaryHeader: {
// marginTop: 5,
// paddingVertical: 5,
// fontSize: 15,
// fontWeight: "bold",
// },
showMoreButton: {
fontWeight: "bold",
position: "absolute",
bottom: 0,
right: 18,
paddingLeft: 6,
},
});
@@ -0,0 +1,57 @@
import React from "react";
import { StyleSheet } from "react-native";
import { Button } from "react-native-paper";
const ReadButton = ({ navigation, novel, chapters, theme, lastRead }) => {
return (
chapters.length > 0 &&
(lastRead ? (
<Button
color="white"
style={[
{ backgroundColor: theme.colorAccent },
styles.startButton,
]}
uppercase={false}
labelStyle={{ letterSpacing: 0, color: theme.colorButtonText }}
onPress={() => {
navigation.navigate("Chapter", {
chapterId: lastRead.chapterId,
chapterUrl: lastRead.chapterUrl,
novelUrl: novel.novelUrl,
novelId: lastRead.novelId,
sourceId: novel.sourceId,
chapterName: lastRead.chapterName,
novelName: novel.novelName,
});
}}
>
{novel.unread ? `Start reading ` : `Continue reading `}
{lastRead.chapterName}
</Button>
) : (
<Button
color={theme.textColorHint}
style={[
styles.startButton,
{ backgroundColor: theme.colorDisabled },
]}
uppercase={false}
labelStyle={{ letterSpacing: 0 }}
>
All chapters read
</Button>
))
);
};
export default ReadButton;
const styles = StyleSheet.create({
startButton: {
marginTop: 8,
marginBottom: 16,
marginHorizontal: 16,
},
});
+212
View File
@@ -0,0 +1,212 @@
import React from "react";
import { View, Text, StyleSheet, Clipboard } from "react-native";
import * as WebBrowser from "expo-web-browser";
import { TouchableRipple, IconButton } from "react-native-paper";
import { followNovelAction } from "../../../redux/novel/novel.actions";
import { useTrackingStatus } from "../../../Hooks/reduxHooks";
import {
CoverImage,
NovelAuthor,
NovelInfo,
NovelInfoContainer,
NovelThumbnail,
NovelTitle,
FollowButton,
TrackerButton,
NovelGenres,
} from "./Info/NovelDetailsComponents";
import { Row } from "../../../Components/Common";
import NovelSummary from "./Info/NovelSummary";
import ReadButton from "./Info/ReadButton";
import { showToast } from "../../../Hooks/showToast";
const NovelInfoHeader = ({
item,
novel,
theme,
filter,
loading,
dispatch,
chapters,
lastRead,
navigation,
trackerSheetRef,
chaptersSettingsSheetRef,
}) => {
const { tracker, trackedNovels } = useTrackingStatus();
let isTracked = false;
if (!loading) {
isTracked = trackedNovels.find((obj) => obj.novelId === novel.novelId);
}
return (
<View>
<CoverImage source={{ uri: item.novelCover }} theme={theme}>
<NovelInfoContainer>
<NovelThumbnail source={{ uri: item.novelCover }} />
<View style={styles.novelDetails}>
<NovelTitle
theme={theme}
onPress={() =>
navigation.navigate("GlobalSearch", {
novelName: item.novelName,
})
}
onLongPress={() => {
Clipboard.setString(item.novelName);
showToast(
"Copied to clipboard: " + item.novelName
);
}}
>
{item.novelName}
</NovelTitle>
{!loading && (
<>
<NovelAuthor theme={theme}>
{novel.author}
</NovelAuthor>
<NovelInfo theme={theme}>
{novel.status}
</NovelInfo>
<NovelInfo theme={theme}>
{novel.source}
</NovelInfo>
</>
)}
</View>
</NovelInfoContainer>
</CoverImage>
{!loading && (
<>
<Row
style={{
justifyContent: "space-around",
paddingHorizontal: 16,
flex: 1,
}}
>
<FollowButton
theme={theme}
followed={novel.followed}
onPress={() => dispatch(followNovelAction(novel))}
/>
{tracker && (
<TrackerButton
theme={theme}
isTracked={isTracked}
onPress={() => trackerSheetRef.current.show()}
/>
)}
<View style={{ alignItems: "center", flex: 1 }}>
<IconButton
icon="swap-vertical-variant"
color={theme.textColorSecondary}
size={24}
style={{ margin: 0 }}
onPress={() =>
navigation.navigate("MigrateNovel", {
sourceId: novel.sourceId,
novelName: novel.novelName,
})
}
/>
<Text
style={{
fontSize: 12,
color: theme.textColorSecondary,
}}
>
Migrate
</Text>
</View>
<View style={{ alignItems: "center", flex: 1 }}>
<IconButton
icon="earth"
color={theme.textColorSecondary}
size={24}
style={{ margin: 0 }}
onPress={() =>
WebBrowser.openBrowserAsync(novel.sourceUrl)
}
/>
<Text
style={{
fontSize: 12,
color: theme.textColorSecondary,
}}
>
WebView
</Text>
</View>
</Row>
<NovelSummary
summary={novel.novelSummary}
followed={novel.followed}
theme={theme}
/>
<NovelGenres theme={theme} genre={novel.genre} />
<ReadButton
novel={novel}
chapters={chapters}
navigation={navigation}
theme={theme}
lastRead={lastRead}
/>
<TouchableRipple
style={styles.bottomsheet}
onPress={() => chaptersSettingsSheetRef.current.show()}
rippleColor={theme.rippleColor}
>
<>
<Text
style={[
{ color: theme.textColorPrimary },
styles.chapters,
]}
>
{`${chapters.length} Chapters`}
</Text>
<IconButton
icon="filter-variant"
color={
filter
? theme.filterColor
: theme.textColorPrimary
}
size={24}
/>
</>
</TouchableRipple>
</>
)}
</View>
);
};
export default NovelInfoHeader;
const styles = StyleSheet.create({
novelDetails: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 8,
},
chapters: {
paddingHorizontal: 16,
paddingVertical: 4,
fontSize: 16,
fontWeight: "bold",
},
bottomsheet: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingRight: 12,
},
});
@@ -0,0 +1,194 @@
import React from "react";
import { StyleSheet, View, Text, Image } from "react-native";
import { TouchableRipple, IconButton } from "react-native-paper";
import { useDispatch } from "react-redux";
import { untrackNovel } from "../../../../redux/tracker/tracker.actions";
export const AddMalTrackingCard = ({ theme, setTrackSearchDialog }) => (
<View
style={[
styles.addCardContainer,
{ backgroundColor: theme.colorPrimary },
]}
>
<Image
source={require("../../../../../assets/mal.png")}
style={styles.trackerIcon}
/>
<TouchableRipple
style={styles.rippleContainer}
borderless
onPress={() => setTrackSearchDialog(true)}
rippleColor={theme.colorAccent}
>
<Text
style={{
flex: 1,
textAlignVertical: "center",
color: theme.colorAccent,
}}
>
Add Tracking
</Text>
</TouchableRipple>
</View>
);
export const MalTrackItemCard = ({
trackItem,
setTrackStatusDialog,
setTrackChaptersDialog,
setTrackScoreDialog,
getStatus,
theme,
}) => {
const dispatch = useDispatch();
return (
<View
style={[
styles.cardContainer,
{ backgroundColor: theme.colorPrimary },
]}
>
<View
style={[
styles.titleContainer,
{ borderBottomColor: theme.dividerColor },
]}
>
<Image
source={require("../../../../../assets/mal.png")}
style={[styles.trackerIcon, { borderBottomLeftRadius: 0 }]}
/>
<View style={styles.listItemContainer}>
<Text
style={[
styles.listItem,
{ color: theme.textColorSecondary },
]}
>
{trackItem.title}
</Text>
<IconButton
icon="close"
color={theme.textColorSecondary}
size={21}
onPress={() => dispatch(untrackNovel(trackItem.id))}
/>
</View>
</View>
<View style={{ height: 50, flexDirection: "row" }}>
<TouchableRipple
style={[
styles.listItemLeft,
{ borderRightColor: theme.dividerColor },
]}
borderless
onPress={() => setTrackStatusDialog(true)}
rippleColor={theme.rippleColor}
>
<Text
style={[
styles.listItem,
{ color: theme.textColorSecondary },
]}
>
{getStatus(trackItem.my_list_status.status)}
</Text>
</TouchableRipple>
<TouchableRipple
style={{ flex: 1 }}
borderless
onPress={() => setTrackChaptersDialog(true)}
rippleColor={theme.rippleColor}
>
<Text
style={[
styles.listItem,
{ color: theme.textColorSecondary },
]}
>
{`${trackItem.my_list_status.num_chapters_read}/${
trackItem.num_chapters !== 0
? trackItem.num_chapters
: "-"
}`}
</Text>
</TouchableRipple>
<TouchableRipple
style={[
styles.listItemRight,
{ borderLeftColor: theme.dividerColor },
]}
borderless
onPress={() => setTrackScoreDialog(true)}
rippleColor={theme.rippleColor}
>
<Text
style={[
styles.listItem,
{ color: theme.textColorSecondary },
]}
>
{trackItem.my_list_status.score === 0
? "-"
: trackItem.my_list_status.score}
</Text>
</TouchableRipple>
</View>
</View>
);
};
const styles = StyleSheet.create({
trackerIcon: {
height: 50,
width: 50,
borderTopLeftRadius: 4,
borderBottomLeftRadius: 4,
},
rippleContainer: {
flex: 1,
alignItems: "center",
borderTopRightRadius: 4,
borderBottomRightRadius: 4,
},
addCardContainer: {
flexDirection: "row",
alignItems: "center",
margin: 8,
borderRadius: 4,
elevation: 2,
},
cardContainer: {
margin: 8,
elevation: 2,
borderRadius: 4,
},
titleContainer: {
flexDirection: "row",
alignItems: "center",
borderBottomWidth: 1,
},
listItemContainer: {
flex: 1,
flexDirection: "row",
borderTopRightRadius: 4,
},
listItem: {
flex: 1,
textAlign: "center",
textAlignVertical: "center",
},
listItemLeft: {
flex: 1,
borderRightWidth: 1,
borderBottomLeftRadius: 4,
},
listItemRight: {
flex: 1,
borderLeftWidth: 1,
borderBottomRightRadius: 4,
},
});
@@ -0,0 +1,67 @@
import React, { useState } from "react";
import { StyleSheet, Text } from "react-native";
import { Modal, TextInput } from "react-native-paper";
import {
RadioButton,
RadioButtonGroup,
} from "../../../../Components/RadioButton";
const SetTrackChaptersDialog = ({
trackItem,
trackChaptersDialog,
setTrackChaptersDialog,
updateTrackChapters,
theme,
}) => {
const [trackChapters, setTrackChapters] = useState(
trackItem.my_list_status.num_chapters_read
);
return (
<Modal
visible={trackChaptersDialog}
onDismiss={() => setTrackChaptersDialog(false)}
contentContainerStyle={[
styles.containerStyle,
{ backgroundColor: theme.colorPrimary },
]}
>
<Text
style={[styles.dialogTitle, { color: theme.textColorPrimary }]}
>
Chapters
</Text>
<TextInput
value={trackChapters.toString()}
onChangeText={(text) => setTrackChapters(text ? +text : "")}
onSubmitEditing={() => updateTrackChapters(trackChapters)}
mode="outlined"
keyboardType="numeric"
theme={{
colors: {
primary: theme.colorAccent,
placeholder: theme.textColorHint,
text: theme.textColorPrimary,
background: "transparent",
},
}}
underlineColor={theme.textColorHint}
dense
/>
</Modal>
);
};
export default SetTrackChaptersDialog;
const styles = StyleSheet.create({
containerStyle: {
margin: 30,
padding: 20,
borderRadius: 8,
},
dialogTitle: {
fontSize: 18,
marginBottom: 16,
},
});
@@ -0,0 +1,78 @@
import React from "react";
import { StyleSheet, Text } from "react-native";
import { Modal } from "react-native-paper";
import {
RadioButton,
RadioButtonGroup,
} from "../../../../Components/RadioButton";
const SetTrackScoreDialog = ({
trackItem,
trackScoreDialog,
setTrackScoreDialog,
updateTrackScore,
theme,
}) => {
const getScoreLabel = (score) => {
const myanimeListScores = {
0: "No Score",
1: "Apalling",
2: "Horrible",
3: "Very Bad",
4: "Bad",
5: "Average",
6: "Fine",
7: "Good",
8: "Very Good",
9: "Great",
10: "Masterpiece",
};
return `(${score}) ${myanimeListScores[score]}`;
};
return (
<Modal
visible={trackScoreDialog}
onDismiss={() => setTrackScoreDialog(false)}
contentContainerStyle={[
styles.containerStyle,
{ backgroundColor: theme.colorPrimary },
]}
theme={{ colors: { backdrop: "rgba(0,0,0,0.25)" } }}
>
<Text
style={[styles.dialogTitle, { color: theme.textColorPrimary }]}
>
Score
</Text>
<RadioButtonGroup
onValueChange={updateTrackScore}
value={trackItem.my_list_status.score}
>
{[...Array(11).keys()].map((item, index) => (
<RadioButton
key={index}
value={item}
label={getScoreLabel(item)}
theme={theme}
/>
))}
</RadioButtonGroup>
</Modal>
);
};
export default SetTrackScoreDialog;
const styles = StyleSheet.create({
containerStyle: {
margin: 30,
padding: 20,
borderRadius: 8,
},
dialogTitle: {
fontSize: 18,
marginBottom: 16,
},
});
@@ -0,0 +1,67 @@
import React from "react";
import { StyleSheet, Text } from "react-native";
import { Modal } from "react-native-paper";
import {
RadioButton,
RadioButtonGroup,
} from "../../../../Components/RadioButton";
const SetTrackStatusDialog = ({
trackItem,
trackStatusDialog,
setTrackStatusDialog,
updateTrackStatus,
theme,
}) => {
const myAnimeListStatus = {
reading: "Reading",
completed: "Completed",
on_hold: "On Hold",
dropped: "Dropped",
plan_to_read: "Plan to read",
};
return (
<Modal
visible={trackStatusDialog}
onDismiss={() => setTrackStatusDialog(false)}
contentContainerStyle={[
styles.containerStyle,
{ backgroundColor: theme.colorPrimary },
]}
>
<Text
style={[styles.dialogTitle, { color: theme.textColorPrimary }]}
>
Status
</Text>
<RadioButtonGroup
onValueChange={updateTrackStatus}
value={trackItem.my_list_status.status}
>
{Object.keys(myAnimeListStatus).map((key, index) => (
<RadioButton
key={index}
value={key}
label={myAnimeListStatus[key]}
theme={theme}
/>
))}
</RadioButtonGroup>
</Modal>
);
};
export default SetTrackStatusDialog;
const styles = StyleSheet.create({
containerStyle: {
margin: 30,
padding: 20,
borderRadius: 8,
},
dialogTitle: {
fontSize: 18,
marginBottom: 16,
},
});
@@ -0,0 +1,184 @@
import React, { useEffect, useState } from "react";
import { StyleSheet, Text, View, Image, ActivityIndicator } from "react-native";
import { Button, Modal, TextInput, TouchableRipple } from "react-native-paper";
import { searchNovels } from "../../../../Services/Trackers/myAnimeList";
import { useSelector, useDispatch } from "react-redux";
import { ScrollView } from "react-native-gesture-handler";
import { trackNovel } from "../../../../redux/tracker/tracker.actions";
const TrackSearchDialog = ({
trackSearchDialog,
setTrackSearchDialog,
novelId,
novelName,
theme,
}) => {
const [loading, setLoading] = useState(true);
const [searchResults, setSearchResults] = useState([]);
const [searchText, setSearchText] = useState(novelName);
const [selectedNovel, setSelectedNovel] = useState();
const tracker = useSelector((state) => state.trackerReducer.tracker);
const dispatch = useDispatch();
const getSearchresults = async () => {
const res = await searchNovels(searchText, tracker.access_token);
setSearchResults(res);
setLoading(false);
};
useEffect(() => {
trackSearchDialog && getSearchresults();
}, [trackSearchDialog]);
const renderSearchResultCard = (item) => (
<TouchableRipple
style={[
{ flexDirection: "row", borderRadius: 4, margin: 8 },
selectedNovel &&
selectedNovel.id === item.node.id && {
backgroundColor: theme.rippleColor,
},
]}
key={item.node.id}
onPress={() => setSelectedNovel(item.node)}
rippleColor={theme.rippleColor}
borderless
>
<>
<Image
source={{ uri: item.node.main_picture.large }}
style={{ height: 150, width: 100, borderRadius: 4 }}
/>
<Text
style={{
flex: 1,
color: theme.textColorPrimary,
marginLeft: 20,
fontSize: 16,
flexWrap: "wrap",
padding: 8,
paddingLeft: 0,
}}
>
{item.node.title}
</Text>
</>
</TouchableRipple>
);
return (
<Modal
visible={trackSearchDialog}
onDismiss={() => setTrackSearchDialog(false)}
contentContainerStyle={[
styles.containerStyle,
{ backgroundColor: theme.colorPrimary },
]}
theme={{ colors: { backdrop: "rgba(0,0,0,0.25)" } }}
>
<TextInput
value={searchText}
onChangeText={(text) => setSearchText(text)}
onSubmitEditing={getSearchresults}
theme={{
colors: {
primary: theme.colorAccent,
text: theme.textColorPrimary,
background: "transparent",
},
}}
underlineColor={theme.textColorHint}
right={
<TextInput.Icon
color={theme.textColorSecondary}
icon="close"
onPress={() => setSearchText("")}
/>
}
/>
<ScrollView
style={{ flexGrow: 1, maxHeight: 500, marginVertical: 8 }}
>
{loading ? (
<ActivityIndicator
color={theme.colorAccent}
size={45}
style={{ margin: 16 }}
/>
) : (
searchResults &&
searchResults.data &&
searchResults.data.map((result) =>
renderSearchResultCard(result)
)
)}
</ScrollView>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
marginTop: 30,
}}
>
<Button
labelStyle={{
color: theme.colorAccent,
letterSpacing: 0,
textTransform: "none",
}}
theme={{ colors: { primary: theme.colorAccent } }}
onPress={() => setSelectedNovel(null)}
>
Remove
</Button>
<View style={{ flexDirection: "row" }}>
<Button
labelStyle={{
color: theme.colorAccent,
letterSpacing: 0,
textTransform: "none",
}}
theme={{ colors: { primary: theme.colorAccent } }}
onPress={() => setTrackSearchDialog(false)}
>
Cancel
</Button>
<Button
labelStyle={{
color: theme.colorAccent,
letterSpacing: 0,
textTransform: "none",
}}
theme={{ colors: { primary: theme.colorAccent } }}
onPress={() => {
if (selectedNovel) {
dispatch(
trackNovel(
{ ...selectedNovel, novelId },
tracker.access_token
)
);
}
setTrackSearchDialog(false);
}}
>
OK
</Button>
</View>
</View>
</Modal>
);
};
export default TrackSearchDialog;
const styles = StyleSheet.create({
containerStyle: {
padding: 20,
margin: 20,
borderRadius: 6,
},
});
@@ -0,0 +1,180 @@
import React, { useState, useEffect } from "react";
import { StyleSheet, View, ToastAndroid } from "react-native";
import { Portal } from "react-native-paper";
import Bottomsheet from "rn-sliding-up-panel";
import { useSelector, useDispatch } from "react-redux";
import { updateTracker } from "../../../../redux/tracker/tracker.actions";
import TrackSearchDialog from "./TrackSearchDialog";
import SetTrackStatusDialog from "./SetTrackStatusDialog";
import SetTrackScoreDialog from "./SetTrackScoreDialog";
import SetTrackChaptersDialog from "./SetTrackChaptersDialog";
import { AddMalTrackingCard, MalTrackItemCard } from "./MyAnimeListCards";
const TrackSheet = ({ bottomSheetRef, novelId, novelName, theme }) => {
const tracker = useSelector((state) => state.trackerReducer.tracker);
const trackedNovels = useSelector(
(state) => state.trackerReducer.trackedNovels
);
const dispatch = useDispatch();
const [trackItem, setTrackItem] = useState();
useEffect(() => {
setTrackItem(trackedNovels.find((obj) => obj.novelId === novelId));
}, [trackedNovels, novelId]);
const [trackSearchDialog, setTrackSearchDialog] = useState(false);
const [trackStatusDialog, setTrackStatusDialog] = useState(false);
const [trackChaptersDialog, setTrackChaptersDialog] = useState(false);
const [trackScoreDialog, setTrackScoreDialog] = useState(false);
const getStatus = (status) => {
const myAnimeListStatus = {
reading: "Reading",
completed: "Completed",
on_hold: "On Hold",
dropped: "Dropped",
plan_to_read: "Plan to read",
};
return myAnimeListStatus[status];
};
const updateTrackChapters = (newChapters) => {
if (newChapters !== "") {
setTrackItem({
...trackItem,
my_list_status: {
...trackItem.my_list_status,
num_chapters_read: newChapters,
},
});
dispatch(
updateTracker(trackItem.id, tracker.access_token, {
...trackItem.my_list_status,
num_chapters_read: newChapters,
})
);
setTrackChaptersDialog(false);
} else {
ToastAndroid.show("Enter a valid number", ToastAndroid.SHORT);
}
};
const updateTrackStatus = (newStatus) => {
setTrackItem({
...trackItem,
my_list_status: {
...trackItem.my_list_status,
status: newStatus,
},
});
dispatch(
updateTracker(trackItem.id, tracker.access_token, {
...trackItem.my_list_status,
status: newStatus,
})
);
setTrackStatusDialog(false);
};
const updateTrackScore = (newScore) => {
setTrackItem({
...trackItem,
my_list_status: {
...trackItem.my_list_status,
score: newScore,
},
});
dispatch(
updateTracker(trackItem.id, tracker.access_token, {
...trackItem.my_list_status,
score: newScore,
})
);
setTrackScoreDialog(false);
};
return (
<>
<Bottomsheet
ref={bottomSheetRef}
draggableRange={{ top: 120, bottom: 0 }}
snappingPoints={[0, 120]}
backdropOpacity={0.25}
>
<View
style={[
styles.contentContainer,
{ backgroundColor: theme.colorPrimary },
]}
>
{!trackItem ? (
<AddMalTrackingCard
theme={theme}
setTrackSearchDialog={setTrackSearchDialog}
/>
) : (
<MalTrackItemCard
trackItem={trackItem}
setTrackStatusDialog={setTrackStatusDialog}
setTrackChaptersDialog={setTrackChaptersDialog}
setTrackScoreDialog={setTrackScoreDialog}
theme={theme}
getStatus={getStatus}
/>
)}
</View>
</Bottomsheet>
<Portal>
{trackItem ? (
<>
<SetTrackStatusDialog
trackItem={trackItem}
trackStatusDialog={trackStatusDialog}
setTrackStatusDialog={setTrackStatusDialog}
updateTrackStatus={updateTrackStatus}
theme={theme}
/>
<SetTrackChaptersDialog
trackItem={trackItem}
trackChaptersDialog={trackChaptersDialog}
setTrackChaptersDialog={setTrackChaptersDialog}
updateTrackChapters={updateTrackChapters}
theme={theme}
/>
<SetTrackScoreDialog
trackItem={trackItem}
trackScoreDialog={trackScoreDialog}
setTrackScoreDialog={setTrackScoreDialog}
updateTrackScore={updateTrackScore}
theme={theme}
/>
</>
) : (
<TrackSearchDialog
trackSearchDialog={trackSearchDialog}
setTrackSearchDialog={setTrackSearchDialog}
novelId={novelId}
novelName={novelName}
theme={theme}
/>
)}
</Portal>
</>
);
};
export default TrackSheet;
const styles = StyleSheet.create({
contentContainer: { flex: 1 },
});
+158
View File
@@ -0,0 +1,158 @@
import React, { useState, useEffect, useCallback } from "react";
import {
StyleSheet,
View,
FlatList,
ActivityIndicator,
RefreshControl,
Button,
Text,
} from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { useDispatch, useSelector } from "react-redux";
import EmptyView from "../../Components/EmptyView";
import {
getUpdatesAction,
updateLibraryAction,
} from "../../redux/updates/updates.actions";
import { Appbar } from "./components/Appbar";
import UpdatesItem from "./components/UpdatesItem";
import { useTheme } from "../../Hooks/reduxHooks";
import { Searchbar } from "../../Components/Searchbar";
import moment from "moment";
import { dateFormat } from "../../Services/utils/constants";
const Updates = ({ navigation }) => {
const theme = useTheme();
const dispatch = useDispatch();
const { updates, loading } = useSelector((state) => state.updatesReducer);
const [searchText, setSearchText] = useState("");
const [searchResults, setSearchResults] = useState([]);
const [refreshing, setRefreshing] = useState(false);
useFocusEffect(
useCallback(() => {
dispatch(getUpdatesAction());
}, [getUpdatesAction])
);
const onRefresh = async () => {
setRefreshing(true);
dispatch(updateLibraryAction());
setRefreshing(false);
};
const onPress = (item) =>
navigation.navigate("Chapter", {
chapterId: item.chapterId,
chapterUrl: item.chapterUrl,
sourceId: item.sourceId,
novelUrl: item.novelUrl,
chapterName: item.chapterName,
novelId: item.novelId,
novelName: item.novelName,
});
const renderItem = ({ item }) => (
<UpdatesItem item={item} theme={theme} onPress={() => onPress(item)} />
);
const ListFooterComponent = () =>
loading && <ActivityIndicator size="small" color={theme.colorAccent} />;
const ListEmptyComponent = () =>
!loading && (
<EmptyView icon="(˘・_・˘)" description="No recent updates" />
);
const refreshControl = () => (
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={["white"]}
progressBackgroundColor={theme.colorAccent}
/>
);
const clearSearchbar = () => {
setSearchText("");
};
const onChangeText = (text) => {
setSearchText(text);
let results = [];
text !== "" &&
updates.map((item) => {
const date = item.date;
const chapters = item.chapters.filter((chapter) =>
chapter.novelName.toLowerCase().includes(text.toLowerCase())
);
if (chapters.length > 0) {
results.push({
date,
chapters,
});
}
});
setSearchResults(results);
};
return (
<View
style={[
styles.container,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<Searchbar
placeholder="Search Updates"
searchText={searchText}
clearSearchbar={clearSearchbar}
onChangeText={onChangeText}
left="magnify"
theme={theme}
right="reload"
onPressRight={() => dispatch(updateLibraryAction())}
/>
<FlatList
contentContainerStyle={styles.flatList}
data={searchText ? searchResults : updates}
keyExtractor={(item, index) => index.toString()}
renderItem={({ item }) => (
<FlatList
ListHeaderComponent={
<Text
style={{
paddingHorizontal: 16,
textTransform: "uppercase",
paddingVertical: 8,
color: theme.textColorSecondary,
}}
>
{moment(item.date).calendar(null, dateFormat)}
</Text>
}
data={item.chapters}
keyExtractor={(item) => item.updateId.toString()}
renderItem={renderItem}
/>
)}
ListFooterComponent={ListFooterComponent()}
ListEmptyComponent={ListEmptyComponent()}
refreshControl={refreshControl()}
/>
</View>
);
};
export default Updates;
const styles = StyleSheet.create({
container: { flex: 1 },
flatList: { flexGrow: 1, paddingVertical: 8 },
});
+17
View File
@@ -0,0 +1,17 @@
import React from "react";
import { Appbar as MaterialAppbar } from "react-native-paper";
import { updateLibraryAction } from "../../../redux/updates/updates.actions";
export const Appbar = ({ theme, dispatch }) => (
<MaterialAppbar.Header style={{ backgroundColor: theme.colorPrimary }}>
<MaterialAppbar.Content
title="Updates"
titleStyle={{ color: theme.textColorPrimary }}
/>
<MaterialAppbar.Action
icon="reload"
onPress={() => dispatch(updateLibraryAction())}
/>
</MaterialAppbar.Header>
);
@@ -0,0 +1,65 @@
import React from "react";
import { View, Text, Image, StyleSheet } from "react-native";
import { TouchableRipple } from "react-native-paper";
const UpdateCard = ({ item, theme, onPress }) => {
return (
<TouchableRipple
style={styles.updateCard}
onPress={onPress}
rippleColor={theme.rippleColor}
borderless
>
<>
<Image
source={{ uri: item.novelCover }}
style={styles.updateIcon}
/>
<View style={styles.chapterDetails}>
<Text
style={[
{ color: theme.textColorPrimary, fontSize: 14 },
item.read && {
color: theme.textColorHint,
},
]}
numberOfLines={1}
>
{item.novelName}
</Text>
<Text
style={[
{ color: theme.textColorPrimary, fontSize: 12 },
item.read && {
color: theme.textColorHint,
},
]}
numberOfLines={1}
>
{item.chapterName}
</Text>
</View>
</>
</TouchableRipple>
);
};
export default UpdateCard;
const styles = StyleSheet.create({
updateCard: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 16,
},
updateIcon: {
width: 42,
height: 42,
borderRadius: 4,
},
chapterDetails: {
flex: 1,
marginLeft: 16,
},
});
@@ -1,5 +1,7 @@
import { showToast } from "../../Hooks/showToast";
export const fetchSources = async () => {
const url = `https://lnreader-extensions.herokuapp.com/api/`;
const url = `https://lnreader-extensions.vercel.app/api/`;
let res = await fetch(url);
let sources = await res.json();
@@ -8,7 +10,7 @@ export const fetchSources = async () => {
};
export const fetchNovel = async (sourceId, novelUrl) => {
const url = `https://lnreader-extensions.herokuapp.com/api/${sourceId}/novel/${novelUrl}`;
const url = `https://lnreader-extensions.vercel.app/api/${sourceId}/novel/${novelUrl}`;
let data = await fetch(url);
let res = await data.json();
@@ -33,10 +35,21 @@ export const fetchNovel = async (sourceId, novelUrl) => {
};
export const fetchChapter = async (sourceId, novelUrl, chapterUrl) => {
const url = `https://lnreader-extensions.herokuapp.com/api/${sourceId}/novel/${novelUrl}${chapterUrl}`;
const url = `https://lnreader-extensions.vercel.app/api/${sourceId}/novel/${novelUrl}${chapterUrl}`;
let res = await fetch(url);
let chapter = await res.json();
return chapter;
};
export const fetchChapters = async (sourceId, novelUrl) => {
const url = `https://lnreader-extensions.vercel.app/api/${sourceId}/novel/${novelUrl}`;
let data = await fetch(url);
let res = await data.json();
const chapters = res.novelChapters;
return chapters;
};
+171
View File
@@ -0,0 +1,171 @@
import * as Linking from "expo-linking";
import qs from "qs";
const clientId = "d2f499825d64b0213bb77e78193ccbb1";
const baseOAuthUrl = "https://myanimelist.net/v1/oauth2/authorize";
const baseApiUrl = "https://api.myanimelist.net/v2";
const pkceChallenger = () => {
const MAX_LENGTH = 88;
let code = "";
const codes =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const randomPicker = () => Math.floor(Math.random() * codes.length);
for (let index = 0; index < MAX_LENGTH; index++) {
code += codes.charAt(randomPicker());
}
return code;
};
const codeChallenge = pkceChallenger();
const redirectUri = Linking.makeUrl();
export const authUrl = `${baseOAuthUrl}?response_type=code&client_id=${clientId}&code_challenge_method=plain&code_challenge=${codeChallenge}`;
const tokenUrl = `https://myanimelist.net/v1/oauth2/token`;
export const myAnimeListConfig = {
clientId,
redirectUri,
authUrl,
codeChallenge,
tokenUrl,
};
export const getAccessToken = async (code, codeChallenge) => {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
};
const body = {
client_id: myAnimeListConfig.clientId,
grant_type: "authorization_code",
code,
code_verifier: codeChallenge,
};
const response = await fetch(myAnimeListConfig.tokenUrl, {
headers,
method: "POST",
body: qs.stringify(body),
});
let tokenResponse = await response.json();
tokenResponse = {
...tokenResponse,
expires_in: new Date(Date.now() + tokenResponse.expires_in),
};
console.log(tokenResponse);
return tokenResponse;
};
export const searchNovels = async (novelName, bearerToken) => {
const searchrl = `${baseApiUrl}/manga?q=${novelName}`;
const headers = {
Authorization: "Bearer " + bearerToken,
"Content-Type": "application/x-www-form-urlencoded",
};
const res = await fetch(searchrl, { method: "GET", headers });
const searchResults = await res.json();
return searchResults;
};
export const findListItem = async (malId, bearerToken) => {
const url = `${baseApiUrl}/manga/${malId}?fields=id,num_chapters,my_list_status{start_date,finish_date}`;
const headers = {
Authorization: "Bearer " + bearerToken,
"Content-Type": "application/x-www-form-urlencoded",
};
const res = await fetch(url, { method: "GET", headers });
const listItem = await res.json();
if (!listItem.my_list_status) {
listItem.my_list_status = {
status: "reading",
score: 0,
num_chapters_read: 0,
};
}
return listItem;
};
export const updateItem = async (malId, bearerToken, body) => {
const url = `${baseApiUrl}/manga/${malId}/my_list_status`;
const headers = {
Authorization: "Bearer " + bearerToken,
"Content-Type": "application/x-www-form-urlencoded",
};
const res = await fetch(url, {
method: "PUT",
headers,
body: qs.stringify(body),
});
const listItem = await res.json();
console.log(listItem);
return listItem;
};
export const updateMalChaptersRead = async (malId, bearerToken, body) => {
const url = `${baseApiUrl}/manga/${malId}/my_list_status`;
const headers = {
Authorization: "Bearer " + bearerToken,
"Content-Type": "application/x-www-form-urlencoded",
};
const res = await fetch(url, {
method: "PUT",
headers,
body: qs.stringify(body),
});
const listItem = await res.json();
console.log(listItem.num_chapters_read);
return listItem.num_chapters_read;
};
const revalidateMalToken = async (refreshToken) => {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
};
const body = {
grant_type: "refresh_token",
refresh_token: refreshToken,
};
const res = await fetch(url, {
headers,
method: "POST",
body: qs.stringify(body),
});
let tokenResponse = res.json();
return tokenResponse;
};
export const malTokenWatcher = async (tracker) => {
if (tracker) {
const date = new Date(Date.now());
if (tracker.expires_in < date) {
return await revalidateMalToken(tracker.refresh_token);
}
}
};
+76
View File
@@ -0,0 +1,76 @@
import * as DocumentPicker from "expo-document-picker";
import { StorageAccessFramework } from "expo-file-system";
import { getLibrary } from "../Database/queries/LibraryQueries";
import { restoreLibrary } from "../Database/queries/NovelQueries";
import { showToast } from "../Hooks/showToast";
import * as Notifications from "expo-notifications";
Notifications.setNotificationHandler({
handleNotification: async () => {
return {
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
};
},
});
export const createBackup = async () => {
const novels = await getLibrary();
const permissions =
await StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!permissions.granted) {
return;
}
const uri = permissions.directoryUri;
if (uri) {
const fileUri = await StorageAccessFramework.createFileAsync(
uri,
"backup",
"application/json"
);
await StorageAccessFramework.writeAsStringAsync(
fileUri,
JSON.stringify(novels)
);
showToast("Backup created");
}
};
export const restoreBackup = async () => {
const backup = await DocumentPicker.getDocumentAsync();
Notifications.scheduleNotificationAsync({
content: { title: "Restoring backup" },
trigger: null,
});
if (backup.uri) {
let novels = await StorageAccessFramework.readAsStringAsync(backup.uri);
novels = await JSON.parse(novels);
novels.map((novel, index) => {
setTimeout(async () => {
if (index + 1 === novels.length) {
Notifications.scheduleNotificationAsync({
content: {
title: "Library Restored",
body: novels.length + " novels restored",
},
trigger: null,
});
}
await restoreLibrary(novel);
}, 1000 * index);
});
}
};

Some files were not shown because too many files have changed in this diff Show More