Compare commits

...

93 Commits

Author SHA1 Message Date
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
Rajarshee Chatterjee 4eb225cdf3 Rewrite database 2021-03-31 09:43:07 +05:30
Rajarshee Chatterjee 3cb41c8bfb Fix history table 2021-03-31 09:35:21 +05:30
Rajarshee Chatterjee 05511e073e Update redux folder structure 2021-03-30 23:39:55 +05:30
Rajarshee Chatterjee 085b9b9eb3 Fix updating novels 2021-03-30 09:17:20 +05:30
Rajarshee Chatterjee 9290d59f77 Create history queries 2021-03-29 15:59:45 +05:30
Rajarshee Chatterjee c0345ae9a9 Add downloaded and unread badges 2021-03-29 12:17:37 +05:30
Rajarshee Chatterjee c12a7f378a Move out database logic 2021-03-29 08:56:24 +05:30
Rajarshee Chatterjee d3c2d130e1 Create table and index queries 2021-03-26 09:34:25 +05:30
Rajarshee Chatterjee 134c72a683 Create database version 2 2021-03-26 08:39:39 +05:30
Rajarshee Chatterjee 4c326e35c0 Add updating a novel 2021-03-23 21:11:57 +05:30
Rajarshee Chatterjee 419420efd1 Add items per row settings 2021-03-23 18:34:58 +05:30
Rajarshee Chatterjee 871fc905ce Fix sorting and filter options 2021-03-23 07:59:08 +05:30
Rajarshee Chatterjee 5a8c652f68 Move delete chapter logic into redux 2021-03-22 21:34:04 +05:30
Rajarshee Chatterjee 97f2f84fa5 Move download chapter logic into redux 2021-03-22 20:52:39 +05:30
Rajarshee Chatterjee 65a3e8fd2d Fix statusbar 2021-03-22 14:23:32 +05:30
Rajarshee Chatterjee d32cb41475 Move some reader logic into redux 2021-03-22 09:20:20 +05:30
Rajarshee Chatterjee 5bb3be576e Fix chapters not marked as read 2021-03-22 07:38:09 +05:30
Rajarshee Chatterjee 2f09b7e29e Fix unique constraint in DownloadsTable 2021-03-22 07:26:32 +05:30
Rajarshee Chatterjee b672aba6ff Move chapter logic into redux 2021-03-21 21:15:24 +05:30
Rajarshee Chatterjee 80363d6016 Fix chapters flatlist not responding while loading 2021-03-21 18:39:16 +05:30
Rajarshee Chatterjee 195fcaf9b2 Move history logic into redux 2021-03-21 15:39:10 +05:30
Rajarshee Chatterjee 5ec08b3add Move more novel logic into redux 2021-03-21 11:11:09 +05:30
Rajarshee Chatterjee 625cdff212 Use redux for novel details and chapters 2021-03-21 09:18:49 +05:30
Rajarshee Chatterjee 83afe9c254 Refactor search and extension UI 2021-03-20 14:28:16 +05:30
Rajarshee Chatterjee 404f59323a Update version 2021-03-20 14:13:21 +05:30
Rajarshee Chatterjee 20ad556039 Change theme variables 2021-03-20 13:58:51 +05:30
Rajarshee Chatterjee 14c994486c Create ExtensionCard 2021-03-20 13:55:48 +05:30
Rajarshee Chatterjee c6884458a1 Update BoxNovel extension UI and refactor settings 2021-03-20 12:37:27 +05:30
Rajarshee Chatterjee 9dab2eccaa Update ReadLightNovel extension UI 2021-03-20 11:53:18 +05:30
Rajarshee Chatterjee 2e21a9fd33 UI improvements 2021-03-20 08:55:32 +05:30
Rajarshee Chatterjee 781e8df740 Redo previous changes 2021-03-16 14:46:41 +05:30
Rajarshee Chatterjee fa4d810ccb Revert dependency updates 2021-03-16 11:56:44 +05:30
Rajarshee Chatterjee 37f531dd93 Update dependencies 2021-03-16 10:33:16 +05:30
Rajarshee Chatterjee 1ea98ec031 Revert dependency updates 2021-03-16 09:58:15 +05:30
Rajarshee Chatterjee 65e5377345 Refactor history screen 2021-03-16 06:49:05 +05:30
Rajarshee Chatterjee 2b78350ad9 Refactor library screen 2021-03-16 06:13:10 +05:30
Rajarshee Chatterjee 69217b415d Update dependencies 2021-03-16 05:18:22 +05:30
Rajarshee Chatterjee 8c04ee3d33 Minor cleanup 2021-03-15 20:20:22 +05:30
Rajarshee Chatterjee 1f78188fd1 Use redux for library states 2021-03-15 17:10:39 +05:30
Rajarshee Chatterjee 5fb9d70036 Use modals for theme and display settings 2021-03-15 09:52:44 +05:30
Rajarshee Chatterjee b3ec043e32 Use redux to store user preferences 2021-03-15 09:04:26 +05:30
147 changed files with 9903 additions and 5656 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

+21 -47
View File
@@ -1,58 +1,32 @@
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";
import { createTables, createIndexes } from "./src/services/db";
import { setStatusBar } from "./src/services/asyncStorage";
// Redyx
import { Provider } from "react-redux";
import { persistor, store } from "./src/redux/store/configureStore";
import { persistor, store } from "./src/redux/store";
import { PersistGate } from "redux-persist/lib/integration/react";
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);
setStatusBar();
useEffect(() => createDB(), []);
const createDb = () => {
createTables();
createIndexes();
};
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;
+4 -2
View File
@@ -2,7 +2,8 @@
"expo": {
"name": "LNReader",
"slug": "LNReader",
"version": "1.0.0",
"scheme": "lnreader",
"version": "1.0.16",
"orientation": "portrait",
"icon": "./assets/icon.png",
"splash": {
@@ -11,7 +12,8 @@
"backgroundColor": "#000000"
},
"updates": {
"fallbackToCacheTimeout": 0
"fallbackToCacheTimeout": 0,
"enabled": false
},
"assetBundlePatterns": ["**/*"],
"ios": {
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: 12 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+2234 -1877
View File
File diff suppressed because it is too large Load Diff
+60 -49
View File
@@ -1,51 +1,62 @@
{
"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-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-persist": "^6.0.0",
"redux-thunk": "^2.3.0",
"rn-sliding-up-panel": "^2.4.4"
},
"devDependencies": {
"@babel/core": "~7.9.0"
},
"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-app-loading": "^1.0.1",
"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-media-library": "~12.0.2",
"expo-permissions": "^12.0.1",
"expo-sharing": "~9.1.2",
"expo-sqlite": "~9.1.0",
"expo-status-bar": "~1.0.4",
"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-gesture-handler": "~1.10.2",
"react-native-offline": "^5.7.0",
"react-native-paper": "^4.5.0",
"react-native-picker-scrollview": "^1.0.1",
"react-native-reanimated": "~2.1.0",
"react-native-safe-area-context": "3.2.0",
"react-native-screens": "~3.0.0",
"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",
"expo-notifications": "~0.11.6",
"expo-background-fetch": "~9.1.0",
"expo-task-manager": "~9.1.0"
},
"devDependencies": {
"@babel/core": "~7.9.0"
},
"private": true
}
+141
View File
@@ -0,0 +1,141 @@
import React, { useRef } from "react";
import { View, TextInput, StyleSheet } from "react-native";
import {
TouchableRipple,
IconButton,
Appbar as MaterialAppbar,
} from "react-native-paper";
import Constants from "expo-constants";
import { useNavigation } from "@react-navigation/native";
import { useSelector } from "react-redux";
export const Appbar = ({ title, onBackAction, style }) => {
const theme = useSelector((state) => state.settingsReducer.theme);
return (
<MaterialAppbar.Header
style={[{ backgroundColor: theme.colorPrimary }, style]}
>
{onBackAction && (
<MaterialAppbar.BackAction onPress={onBackAction} />
)}
<MaterialAppbar.Content
title={title}
titleStyle={{ color: theme.textColorPrimary }}
/>
</MaterialAppbar.Header>
);
};
export const SearchAppbar = ({
placeholder,
getSearchResults,
setSearchText,
searchText,
getNovels,
setLoading,
screen,
openWebView,
}) => {
const searchRef = useRef(null);
const navigation = useNavigation();
const theme = useSelector((state) => state.settingsReducer.theme);
return (
<TouchableRipple
borderless
onPress={() => searchRef.current.focus()}
style={[
styles.searchAppbarContainer,
{ backgroundColor: theme.searchBarColor },
]}
>
<View
style={{
flex: 1,
flexDirection: "row",
justifyContent: "space-between",
}}
>
<View style={{ flex: 1, flexDirection: "row" }}>
{screen === "Library" ? (
<IconButton
icon="magnify"
color={theme.textColorSecondary}
style={{ marginLeft: 0 }}
size={23}
/>
) : (
<IconButton
icon="arrow-left"
color={theme.textColorSecondary}
style={{ marginLeft: 0 }}
size={23}
onPress={() => navigation.goBack()}
/>
)}
<TextInput
ref={searchRef}
style={{
fontSize: 16,
color: theme.textColorSecondary,
}}
placeholder={placeholder}
placeholderTextColor={theme.textColorSecondary}
onChangeText={(text) => {
setSearchText(text);
if (screen === "Library") {
getSearchResults(text);
}
}}
onSubmitEditing={() => {
if (screen === "Extension") {
getSearchResults(searchText);
}
}}
defaultValue={searchText}
/>
</View>
{searchText !== "" && (
<IconButton
icon="close"
color={theme.textColorSecondary}
style={{ marginRight: 0 }}
size={23}
onPress={() => {
setLoading?.(true);
getNovels();
setSearchText("");
}}
/>
)}
{openWebView && (
<IconButton
icon="earth"
color={theme.textColorSecondary}
style={{ marginRight: 0 }}
size={23}
onPress={openWebView}
/>
)}
</View>
</TouchableRipple>
);
};
const styles = StyleSheet.create({
searchAppbarContainer: {
marginTop: Constants.statusBarHeight + 8,
height: 48,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
marginBottom: 12,
borderRadius: 8,
marginHorizontal: 12,
elevation: 2,
},
});
+23
View File
@@ -0,0 +1,23 @@
import React from "react";
import { View } from "react-native";
import { useSelector } from "react-redux";
const BottomSheetHandle = () => {
const theme = useSelector((state) => state.settingsReducer.theme);
return (
<>
<View
style={{
backgroundColor: theme.textColorHint,
height: 5,
width: 30,
borderRadius: 50,
top: 10,
alignSelf: "center",
}}
/>
</>
);
};
export default BottomSheetHandle;
+48
View File
@@ -0,0 +1,48 @@
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.settingsReducer.theme);
return (
<View style={styles.emptyViewContainer}>
<Text
style={[
styles.emptyViewIcon,
{ color: theme.textColorSecondary },
]}
>
{icon}
</Text>
<Text
style={[
styles.emptyViewText,
{ color: theme.textColorSecondary },
]}
>
{description}
</Text>
</View>
);
};
export default EmptyView;
const styles = StyleSheet.create({
emptyViewContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyViewIcon: {
fontSize: 45,
fontWeight: "bold",
},
emptyViewText: {
fontWeight: "bold",
marginTop: 10,
textAlign: "center",
paddingHorizontal: 30,
},
});
+78
View File
@@ -0,0 +1,78 @@
import React from "react";
import { StyleSheet } from "react-native";
import { List, Divider as PaperDivider } from "react-native-paper";
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 }) => (
<List.Item
title={title}
titleStyle={{ color: theme.textColorPrimary }}
description={description}
descriptionStyle={{ color: theme.textColorSecondary }}
descriptionNumberOfLines={1}
left={() =>
icon && (
<List.Icon
color={theme.colorAccent}
icon={icon}
style={{ marginVertical: 0 }}
/>
)
}
right={() =>
right && (
<List.Icon
color="#47a84a"
icon={right}
style={{ marginVertical: 0 }}
/>
)
}
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,
},
});
+179
View File
@@ -0,0 +1,179 @@
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, itemsPerRow } = 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 = () =>
item.chaptersUnread && (
<Text
style={[
styles.unreadBadge,
!item.chaptersDownloaded && {
borderTopLeftRadius: 4,
borderBottomLeftRadius: 4,
},
]}
>
{item.chaptersUnread}
</Text>
);
const downloadBadge = () =>
item.chaptersDownloaded && (
<Text
style={[
styles.downloadBadge,
!item.chaptersUnread && {
borderTopRightRadius: 4,
borderBottomRightRadius: 4,
},
]}
>
{item.chaptersDownloaded}
</Text>
);
return displayMode !== 2 ? (
<View style={{ flex: 1 / itemsPerRow }}>
<TouchableRipple
borderless
centered
rippleColor={theme.rippleColor}
style={styles.opac}
onPress={onPress}
>
<>
<ImageBackground
source={{ uri: item.novelCover }}
style={{ height: height[itemsPerRow] }}
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,
},
});
+42
View File
@@ -0,0 +1,42 @@
import React from "react";
import { RefreshControl, StyleSheet, FlatList } from "react-native";
import { useSettings } from "../Hooks/reduxHooks";
const NovelList = ({
data,
renderItem,
refreshControl,
ListEmptyComponent,
}) => {
const { displayMode, itemsPerRow } = useSettings();
const getItemsPerRow = () => {
if (displayMode === 2) {
return 1;
} else {
return itemsPerRow;
}
};
return (
<FlatList
contentContainerStyle={styles.flatListCont}
numColumns={getItemsPerRow()}
key={getItemsPerRow()}
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,
},
});
+36
View File
@@ -0,0 +1,36 @@
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 }) => (
<View style={styles.radioButtonContainer}>
<MaterialRadioButton
value={value}
color={theme.colorAccent}
uncheckedColor={theme.textColorSecondary}
/>
<Text
style={[styles.radioButtonLabel, { color: theme.textColorPrimary }]}
>
{label}
</Text>
</View>
);
const styles = StyleSheet.create({
radioButtonContainer: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
},
radioButtonLabel: {
marginLeft: 16,
fontSize: 16,
},
});
+124
View File
@@ -0,0 +1,124 @@
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 { setDisplayMode } from "../redux/settings/settings.actions";
export const Searchbar = ({
theme,
placeholder,
searchText,
left,
onPressLeft,
right,
displayMenu,
onPressRight,
clearSearchbar,
onChangeText,
onSubmitEditing,
}) => {
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(setDisplayMode(getDisplayMode()))
}
/>
)}
{right && (
<IconButton
icon={right}
color={theme.textColorSecondary}
style={{ marginRight: 0 }}
size={23}
onPress={onPressRight}
/>
)}
</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",
},
});
+8
View File
@@ -0,0 +1,8 @@
import React from "react";
import { View, Text as RNText, Image, StyleSheet } from "react-native";
const Text = () => {};
export default UpdateCard;
const styles = StyleSheet.create({});
+55
View File
@@ -0,0 +1,55 @@
import * as SQLite from "expo-sqlite";
import {
createNovelTableQuery,
createUrlIndexQuery,
createLibraryIndexQuery,
} from "./tables/NovelTable";
import {
createChapterTableQuery,
createNovelIdIndexQuery,
createUnreadChaptersIndexQuery,
} from "./tables/ChapterTable";
import {
createHistoryTableQuery,
createChapterIdIndexQuery,
} from "./tables/HistoryTable";
import { createDownloadTableQuery } from "./tables/DownloadTable";
import {
createUpdatesTableQuery,
updatesSeedDataQuery,
} from "./tables/UpdateTable";
/**
* Database Version = 2
*/
const dbName = "lnreader.db";
const db = SQLite.openDatabase(dbName);
export const createDB = () => {
db.transaction((tx) => {
tx.executeSql(createNovelTableQuery);
tx.executeSql(createChapterTableQuery);
tx.executeSql(createHistoryTableQuery);
tx.executeSql(createDownloadTableQuery);
tx.executeSql(createUpdatesTableQuery);
//Db indexes
tx.executeSql(createUrlIndexQuery);
tx.executeSql(createLibraryIndexQuery);
tx.executeSql(createNovelIdIndexQuery);
tx.executeSql(createUnreadChaptersIndexQuery);
tx.executeSql(createChapterIdIndexQuery);
});
};
export const deleteDb = () => {
db.transaction((tx) => {
tx.executeSql("DROP TABLE novels");
tx.executeSql("DROP TABLE chapters");
tx.executeSql("DROP TABLE history");
tx.executeSql("DROP TABLE downloads");
tx.executeSql("DROP TABLE updates");
});
};
+181
View File
@@ -0,0 +1,181 @@
import * as SQLite from "expo-sqlite";
const db = SQLite.openDatabase("lnreader.db");
const insertChaptersQuery = `INSERT INTO chapters (chapterUrl, chapterName, releaseDate, novelId) values (?, ?, ?, ?)`;
export const insertChapters = async (novelId, chapters) => {
db.transaction((tx) => {
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 = (sort, filter) =>
`SELECT * FROM chapters WHERE novelId = ? ${filter || ""} ${
sort || "ORDER BY chapterId ASC"
}`;
export const getChapters = (novelId, sort, filter) => {
return new Promise((resolve, reject) =>
db.transaction((tx) => {
tx.executeSql(
getChaptersQuery(sort, filter),
[novelId],
(txObj, { rows: { _array } }) => {
resolve(_array);
},
(txObj, error) => console.log("Error ", error)
);
})
);
};
const getChapterQuery = `SELECT * FROM downloads WHERE downloadChapterId = ?`;
export const getChapter = async (chapterId) => {
return new Promise((resolve, reject) =>
db.transaction((tx) => {
tx.executeSql(
getChapterQuery,
[chapterId],
(tx, results) => {
resolve(results.rows.item(0));
},
(txObj, error) => console.log("Error ", error)
);
})
);
};
const markChapterReadQuery = `UPDATE chapters SET \`read\` = 1 WHERE chapterId = ?`;
export const markChapterRead = async (chapterId) => {
db.transaction((tx) => {
tx.executeSql(
markChapterReadQuery,
[chapterId],
(tx, res) => {},
(tx, error) => console.log(error)
);
});
};
const isChapterDownloadedQuery = `SELECT * FROM downloads WHERE downloadChapterId=?`;
export const isChapterDownloaded = async (chapterId) => {
return new Promise((resolve, reject) =>
db.transaction((tx) => {
tx.executeSql(
isChapterDownloadedQuery,
[chapterId],
(txObj, res) => {
if (res.rows.length !== 0) {
resolve(true);
} else {
resolve(false);
}
},
(txObj, error) => console.log("Error ", error)
);
})
);
};
const downloadChapterQuery = `INSERT INTO downloads (downloadChapterId, chapterName, chapterText, prevChapter, nextChapter) VALUES (?, ?, ?, ?, ?)`;
export const downloadChapter = async (
extensionId,
novelUrl,
chapterUrl,
chapterId
) => {
const downloadUrl = `https://lnreader-extensions.vercel.app/api/${extensionId}/novel/${novelUrl}${chapterUrl}`;
const response = await fetch(downloadUrl);
const chapter = await response.json();
db.transaction((tx) => {
tx.executeSql(
`UPDATE chapters SET downloaded = 1 WHERE chapterId = ?`,
[chapterId]
);
tx.executeSql(
downloadChapterQuery,
[
chapterId,
chapter.chapterName,
chapter.chapterText,
chapter.prevChapter,
chapter.nextChapter,
],
(tx, res) =>
console.log(`Downloaded Chapter ${chapter.chapterUrl}`),
(txObj, error) => console.log("Error ", error)
);
});
};
export const deleteChapter = async (chapterId) => {
const updateIsDownloadedQuery = `UPDATE chapters SET downloaded = 0 WHERE chapterId=?`;
const deleteChapterQuery = `DELETE FROM downloads WHERE downloadChapterId=?`;
db.transaction((tx) => {
tx.executeSql(
updateIsDownloadedQuery,
[chapterId],
(tx, res) => console.log(`Chaptermarked not downapdeod deleted`),
(txObj, error) => console.log("Error ", error)
);
tx.executeSql(
deleteChapterQuery,
[chapterId],
(tx, res) => console.log(`Chapter deleted`),
(txObj, error) => console.log("Error ", error)
);
});
};
const getLastReadChapterQuery = `
SELECT chapters.*
FROM history
JOIN chapters
ON history.historyChapterId = chapters.chapterId
WHERE history.historyNovelId = ?
`;
export const getLastReadChapter = async (novelId) => {
return new Promise((resolve, reject) => {
db.transaction((tx) => {
tx.executeSql(
getLastReadChapterQuery,
[novelId],
(tx, { rows }) => resolve(rows.item(0)),
(txObj, error) => console.log("Error ", error)
);
});
});
};
+56
View File
@@ -0,0 +1,56 @@
import * as SQLite from "expo-sqlite";
const db = SQLite.openDatabase("lnreader.db");
const getHistoryQuery = `
SELECT history.*, chapters.*, novels.*
FROM history
JOIN chapters
ON history.historyChapterId = chapters.chapterId
JOIN novels
ON history.historyNovelId = novels.novelId
ORDER BY history.historyTimeRead DESC`;
export const getHistory = async () => {
return new Promise((resolve, reject) => {
db.transaction((tx) => {
tx.executeSql(
getHistoryQuery,
null,
(txObj, { rows: { _array } }) => {
resolve(_array);
},
(txObj, error) => console.log("Error ", error)
);
});
});
};
// const insertHistoryQuery =
export const insertHistory = async (novelId, chapterId) => {
db.transaction((tx) => {
tx.executeSql(
"INSERT OR REPLACE INTO history (historyNovelId, historyChapterId, historyTimeRead) VALUES (?, ?, (datetime('now','localtime')))",
[novelId, chapterId],
(txObj, res) => {},
(txObj, error) => console.log("Error ", error)
);
tx.executeSql("UPDATE novels SET unread = 0 WHERE novelId = ?", [
novelId,
]);
});
};
export const deleteHistory = async (novelId) => {
db.transaction((tx) => {
tx.executeSql("DELETE FROM history WHERE historyNovelId = ?", [
novelId,
]);
});
};
export const deleteAllHistory = () => {
db.transaction((tx) => {
tx.executeSql("DELETE FROM history; VACCUM;");
});
};
+73
View File
@@ -0,0 +1,73 @@
import * as SQLite from "expo-sqlite";
const db = SQLite.openDatabase("lnreader.db");
const getLibraryQuery = `
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
GROUP BY novels.novelId
`;
export const getLibrary = () => {
return new Promise((resolve, reject) =>
db.transaction((tx) => {
tx.executeSql(
getLibraryQuery,
null,
(txObj, { rows: { _array } }) => {
// console.log(_array);
resolve(_array);
},
(txObj, error) => console.log("Error ", error)
);
})
);
};
// const searchLibraryQuery = ``;
export const searchLibrary = (searchText) => {
return new Promise((resolve, reject) => {
db.transaction((tx) => {
tx.executeSql(
`
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}%'
GROUP BY novels.novelId
`,
null,
(txObj, { rows: { _array } }) => resolve(_array),
(txObj, error) => console.log("Error ", error)
);
});
});
};
+125
View File
@@ -0,0 +1,125 @@
import * as SQLite from "expo-sqlite";
const db = SQLite.openDatabase("lnreader.db");
import { fetchChapters } 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)
)
)
);
};
+37
View File
@@ -0,0 +1,37 @@
import * as SQLite from "expo-sqlite";
const db = SQLite.openDatabase("lnreader.db");
const getUpdatesQuery = `
SELECT chapters.chapterId, 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)
);
});
};
+18
View File
@@ -0,0 +1,18 @@
export const createChapterTableQuery = `
CREATE TABLE IF NOT EXISTS chapters(
chapterId INTEGER PRIMARY KEY AUTOINCREMENT,
chapterUrl TEXT,
novelId INTEGER NOT NULL,
chapterName TEXT NOT NULL,
releaseDate TEXT,
bookmark BOOLEAN NOT NULL DEFAULT 0,
\`read\` INTEGER NOT NULL DEFAULT 0,
downloaded INTEGER NOT NULL DEFAULT 0,
UNIQUE(novelId, chapterUrl),
FOREIGN KEY (novelId) REFERENCES novels(novelId)
ON DELETE CASCADE
)`;
export const createNovelIdIndexQuery = `CREATE INDEX IF NOT EXISTS chapterNovelIdIndex ON chapters(novelId)`;
export const createUnreadChaptersIndexQuery = `CREATE INDEX IF NOT EXISTS chapterUnreadByNovelIndex ON chapters(novelId, \`read\`)`;
+11
View File
@@ -0,0 +1,11 @@
export const createDownloadTableQuery = `
CREATE TABLE IF NOT EXISTS downloads(
downloadId INTEGER PRIMARY KEY AUTOINCREMENT,
downloadChapterId INTEGER NOT NULL,
chapterName TEXT,
chapterText TEXT,
nextChapter TEXT,
prevChapter TEXT,
FOREIGN KEY (downloadChapterId) REFERENCES chapters(chapterId)
ON DELETE CASCADE
)`;
+11
View File
@@ -0,0 +1,11 @@
export const createHistoryTableQuery = `
CREATE TABLE IF NOT EXISTS history(
historyId INTEGER PRIMARY KEY AUTOINCREMENT,
historyChapterId INTEGER NOT NULL,
historyNovelId INTEGER NOT NULL UNIQUE,
historyTimeRead DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (historyChapterId) REFERENCES chapters(chapterId)
ON DELETE CASCADE
)`;
export const createChapterIdIndexQuery = `CREATE INDEX IF NOT EXISTS historyChapterIdIndex ON history(historyChapterId)`;
+21
View File
@@ -0,0 +1,21 @@
export const createNovelTableQuery = `
CREATE TABLE IF NOT EXISTS novels(
novelId INTEGER PRIMARY KEY AUTOINCREMENT,
novelUrl TEXT NOT NULL,
sourceUrl TEXT UNIQUE NOT NULL,
sourceId INTEGER NOT NULL,
source TEXT NOT NULL,
novelName TEXT NOT NULL,
novelCover TEXT NOT NULL,
novelSummary TEXT,
author TEXT,
artist TEXT,
status TEXT,
genre TEXT,
followed INTEGER DEFAULT 0,
unread INTEGER DEFAULT 1
)`;
export const createUrlIndexQuery = `CREATE INDEX IF NOT EXISTS novelUrlIndex ON novels(novelUrl)`;
export const createLibraryIndexQuery = `CREATE INDEX IF NOT EXISTS novelFollowedIndex ON novels(followed)`;
+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.17";
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;
}
};
+84
View File
@@ -0,0 +1,84 @@
import { useEffect } from "react";
import { useSelector } from "react-redux";
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 { displayMode, itemsPerRow } = useSelector(
(state) => state.settingsReducer
);
return { displayMode, itemsPerRow };
};
const useLibrary = () => {
const library = useSelector((state) => state.libraryReducer.novels);
return library;
};
const useNovel = () => {
const novel = useSelector((state) => state.novelReducer);
return novel;
};
const findNovel = (novelId) => {
let novel = useSelector((state) => state.preferenceReducer.novelSettings);
novel = novel.find((novel) => novel.novelId === novelId);
return novel;
};
const usePreferences = (novelId) => {
let sort, filter;
const novel = findNovel(novelId);
if (novel) {
sort = novel.sort ?? "";
filter = novel.filter ?? "";
}
return { sort, filter };
};
const useContinueReading = (chapters, novelId) => {
let chapter, chapterId, novel;
novel = findNovel(novelId);
if (novel) {
chapterId = novel.lastRead;
}
if (chapterId) {
chapter = chapters.find((obj) => obj.chapterId === chapterId);
}
if (!chapter) {
chapter = chapters.find((obj) => obj.read === 0);
}
return chapter;
};
export {
useTheme,
useReaderSettings,
useLibrary,
useSettings,
usePreferences,
useContinueReading,
useNovel,
};
+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);
};
+92
View File
@@ -0,0 +1,92 @@
import React from "react";
import { createMaterialBottomTabNavigator } from "@react-navigation/material-bottom-tabs";
import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons";
import { useTheme } from "../Hooks/reduxHooks";
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";
const Tab = createMaterialBottomTabNavigator();
const BottomNavigator = () => {
const theme = useTheme();
return (
<Tab.Navigator
// shifting={false}
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>
);
};
export default BottomNavigator;
+69
View File
@@ -0,0 +1,69 @@
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 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>
<View style={{ flex: 1, backgroundColor: theme.colorPrimary }}>
{isNewVersion && <NewUpdateDialog newVersion={latestRelease} />}
<Stack.Navigator screenOptions={stackNavigatorConfig}>
<Stack.Screen
name="BottomNavigator"
component={BottomNavigator}
/>
<Stack.Screen
name="Novel"
component={Novel}
options={{
headerTitle: "",
headerShown: true,
headerTransparent: true,
headerTintColor: "white",
...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.Navigator>
</View>
</NavigationContainer>
);
};
export default MainNavigator;
+28
View File
@@ -0,0 +1,28 @@
import React from "react";
import { createStackNavigator } from "@react-navigation/stack";
import About from "../Screens/More/About";
import Settings from "../Screens/More/Settings";
import Trackers from "../Screens/More/Trackers";
import { View } from "react-native";
import { useTheme } from "../Hooks/reduxHooks";
const Stack = createStackNavigator();
const stackNavigatorConfig = { headerShown: false };
const MoreStack = () => {
const theme = useTheme();
return (
<View style={{ flex: 1, backgroundColor: theme.colorPrimary }}>
<Stack.Navigator screenOptions={stackNavigatorConfig}>
<Stack.Screen name="Settings" component={Settings} />
<Stack.Screen name="Trackers" component={Trackers} />
<Stack.Screen name="About" component={About} />
</Stack.Navigator>
</View>
);
};
export default MoreStack;
+155
View File
@@ -0,0 +1,155 @@
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")}
/>
<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 },
});
+155
View File
@@ -0,0 +1,155 @@
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";
const GlobalSearch = ({ navigation }) => {
const theme = useTheme();
const [loading, setLoading] = useState(false);
const [searchText, setSearchText] = useState("");
const [searchResults, setSearchResults] = useState("");
const { sources, pinned } = useSelector((state) => state.sourceReducer);
const library = useLibrary();
const pinnedSources = sources.filter(
(source) => pinned.indexOf(source.sourceId) !== -1
);
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);
});
}, 3000 * 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}
/>
<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 },
});
@@ -0,0 +1,71 @@
import React from "react";
import { StyleSheet, View, Text, ImageBackground } from "react-native";
import { TouchableRipple } from "react-native-paper";
import { useSettings, 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,
},
});
+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,
},
});
+241
View File
@@ -0,0 +1,241 @@
import React, { useState, useEffect, useRef } from "react";
import { StyleSheet, View, Text, ActivityIndicator } from "react-native";
import { Appbar, Provider, Portal } from "react-native-paper";
import { CollapsibleHeaderScrollView } from "react-native-collapsible-header-views";
import BottomSheet from "./components/BottomSheet";
import { insertHistoryAction } from "../../redux/history/history.actions";
import {
getChapterAction,
markChapterReadAction,
} from "../../redux/novel/novel.actions";
import { connect, useSelector, useDispatch } from "react-redux";
import { parseChapterNumber } from "../../Services/updates";
import { updateChaptersRead } from "../../redux/tracker/tracker.actions";
const ChapterItem = ({
route,
navigation,
theme,
insertHistoryAction,
getChapterAction,
chapter,
loading,
reader,
}) => {
const {
chapterId,
extensionId,
chapterUrl,
novelUrl,
novelId,
} = route.params;
let _panel = useRef(null);
const dispatch = useDispatch();
const trackedNovels = useSelector(
(state) => state.trackerReducer.trackedNovels
);
const tracker = useSelector((state) => state.trackerReducer.tracker);
let isTracked = false;
if (!loading) {
isTracked = trackedNovels.find((obj) => obj.novelId === novelId);
}
useEffect(() => {
getChapterAction(extensionId, chapterUrl, novelUrl, chapterId);
insertHistoryAction(novelId, chapterId);
}, []);
const readerBackground = (val) => {
const backgroundColor = {
1: "#000000",
2: "#FFFFFF",
3: "#F4ECD8",
};
return backgroundColor[val];
};
const readerTextColor = (val) => {
const textColor = val === 1 ? "rgba(255,255,255,0.7)" : "#000000";
return textColor;
};
const chapterNumber = !loading && parseChapterNumber(chapter.chapterName);
const readerLineHeight = (val) => {
const lineHeight = {
12: 20,
14: 22,
16: 25,
18: 26,
20: 28,
};
return lineHeight[val];
};
const isCloseToBottom = ({
layoutMeasurement,
contentOffset,
contentSize,
}) => {
const paddingToBottom = 40;
return (
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom
);
};
return (
<Provider>
<CollapsibleHeaderScrollView
headerContainerBackgroundColor="rgba(0,0,0,0.4)"
CollapsibleHeaderComponent={
<Appbar.Header
style={{
backgroundColor: "transparent",
elevation: 0,
}}
>
<Appbar.BackAction
onPress={() => navigation.goBack()}
color="#FFFFFF"
size={26}
style={{ marginRight: 0 }}
/>
<Appbar.Content
title={loading ? "Chapter" : chapter.chapterName}
titleStyle={{ color: "#FFFFFF" }}
/>
{!loading && (
<>
<Appbar.Action
icon="chevron-left"
size={26}
disabled={!chapter.prevChapter}
onPress={() => {
navigation.replace("Chapter", {
chapterUrl: chapter.prevChapter,
chapterId: chapterId - 1,
extensionId,
novelUrl,
novelId,
chapterName: chapter.chapterName,
});
}}
color="#FFFFFF"
/>
<Appbar.Action
icon="chevron-right"
size={26}
disabled={!chapter.nextChapter}
onPress={() => {
navigation.replace("Chapter", {
chapterUrl: chapter.nextChapter,
extensionId,
novelUrl,
novelId,
chapterId: chapterId + 1,
chapterName: chapter.chapterName,
});
}}
color="#FFFFFF"
/>
<Appbar.Action
icon="dots-vertical"
size={26}
onPress={() =>
_panel.current.show({ velocity: -1.5 })
}
color="#FFFFFF"
/>
</>
)}
</Appbar.Header>
}
headerHeight={100}
contentContainerStyle={[
styles.container,
{ backgroundColor: readerBackground(reader.theme) },
]}
onScroll={({ nativeEvent }) => {
if (isCloseToBottom(nativeEvent)) {
dispatch(markChapterReadAction(chapterId, novelId));
if (
isTracked &&
chapterNumber &&
chapterNumber >
isTracked.my_list_status.num_chapters_read
) {
dispatch(
updateChaptersRead(
isTracked.id,
tracker.access_token,
chapterNumber
)
);
}
}
}}
>
{loading ? (
<View style={{ flex: 1, justifyContent: "center" }}>
<ActivityIndicator
size={50}
color={theme.colorAccent}
/>
</View>
) : (
<Text
style={[
{
paddingVertical: 15,
fontSize: reader.textSize,
color: readerTextColor(reader.theme),
lineHeight: readerLineHeight(reader.textSize),
textAlign: reader.textAlign,
paddingHorizontal: `${reader.padding}%`,
},
reader.fontFamily && {
fontFamily: reader.fontFamily,
},
]}
>
{chapter.chapterText.trim()}
</Text>
)}
<Portal>
<BottomSheet bottomSheetRef={_panel} />
</Portal>
</CollapsibleHeaderScrollView>
</Provider>
);
};
const mapStateToProps = (state) => ({
theme: state.settingsReducer.theme,
chapter: state.novelReducer.chapter,
loading: state.novelReducer.chapterLoading,
reader: state.settingsReducer.reader,
});
export default connect(mapStateToProps, {
insertHistoryAction,
getChapterAction,
})(ChapterItem);
const styles = StyleSheet.create({
container: {
flexGrow: 1,
paddingVertical: 10,
},
});
@@ -0,0 +1,276 @@
import React from "react";
import { Animated, StyleSheet, View, Text, Dimensions } from "react-native";
import { ToggleButton, IconButton, Chip } 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 { connect } from "react-redux";
import {
setReaderFont,
updateReaderPadding,
updateReaderTextAlign,
updateReaderTextSize,
updateReaderTheme,
} from "../../../redux/settings/settings.actions";
import BottomSheetHandle from "../../../Components/BottomSheetHandle";
const ChapterBottomSheet = ({
bottomSheetRef,
theme,
reader,
updateReaderTextSize,
updateReaderTheme,
updateReaderTextAlign,
updateReaderPadding,
setReaderFont,
}) => {
return (
<Bottomsheet
animatedValue={new Animated.Value(0)}
ref={bottomSheetRef}
draggableRange={{ top: 400, bottom: 0 }}
snappingPoints={[0, 400]}
showBackdrop={false}
>
<View style={styles.contentContainer}>
<BottomSheetHandle />
<View style={styles.readerSettingsContainer}>
<Text
style={{
color: "#FFFFFF",
fontWeight: "bold",
}}
>
Text Size
</Text>
<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) => updateReaderTextSize(value)}
/>
<View
style={{
flexDirection: "row",
justifyContent: "center",
}}
>
<View
style={{
justifyContent: "center",
alignItems: "center",
}}
>
<Text
style={{
color: "#FFFFFF",
fontWeight: "bold",
}}
>
Reader Theme
</Text>
<ToggleButton.Row
onValueChange={(value) =>
updateReaderTheme(value)
}
value={reader.theme}
style={{ marginTop: 10 }}
>
<ToggleButton
icon="format-text"
color="#FFFFFF"
value={1}
style={{
backgroundColor: "#000000",
marginHorizontal: 10,
}}
/>
<ToggleButton
icon="format-text"
color="#000000"
value={2}
style={{
backgroundColor: "#FFFFFF",
marginHorizontal: 10,
}}
/>
<ToggleButton
icon="format-text"
color="#000000"
value={3}
style={{
backgroundColor: "#F4ECD8",
marginHorizontal: 10,
}}
/>
</ToggleButton.Row>
</View>
<View
style={{
justifyContent: "center",
alignItems: "center",
}}
>
<Text
style={{
color: "#FFFFFF",
fontWeight: "bold",
}}
>
Text Align
</Text>
<ToggleButton.Row
onValueChange={(value) =>
updateReaderTextAlign(value ?? "left")
}
value={reader.textAlign}
style={{ marginTop: 10 }}
>
<ToggleButton
icon="format-align-left"
color="#000000"
value="left"
style={{
backgroundColor: "#FFFFFF",
marginHorizontal: 10,
}}
/>
<ToggleButton
icon="format-align-justify"
color="#000000"
value="justify"
style={{
backgroundColor: "#FFFFFF",
marginHorizontal: 10,
}}
/>
<ToggleButton
icon="format-align-right"
color="#000000"
value="right"
style={{
backgroundColor: "#FFFFFF",
marginHorizontal: 10,
}}
/>
</ToggleButton.Row>
</View>
</View>
<Text
style={{
color: "#FFFFFF",
fontWeight: "bold",
marginTop: 10,
}}
>
Padding
</Text>
<View
style={{
width: "100%",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
}}
>
<IconButton
icon="minus"
color={theme.colorAccent}
size={26}
disabled={reader.padding <= 0 ? true : false}
onPress={() =>
updateReaderPadding(reader.padding - 1)
}
/>
<Text
style={{
color: "#FFFFFF",
paddingHorizontal: 50,
}}
>
{`${reader.padding}%`}
</Text>
<IconButton
icon="plus"
color={theme.colorAccent}
size={26}
disabled={reader.padding >= 10 ? true : false}
onPress={() =>
updateReaderPadding(reader.padding + 1)
}
/>
</View>
<Text
style={{
color: "#FFFFFF",
fontWeight: "bold",
marginTop: 10,
}}
>
Font Style
</Text>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
}}
>
{fonts.map((font, index) => (
<Chip
key={index}
onPress={() => setReaderFont(font.fontFamily)}
textStyle={{ fontFamily: font.fontFamily }}
style={{
borderWidth: 0,
marginHorizontal: 5,
marginVertical: 5,
}}
selected={
reader.fontFamily === font.fontFamily
? true
: false
}
>
{font.name}
</Chip>
))}
</View>
</View>
</View>
</Bottomsheet>
);
};
const mapStateToProps = (state) => ({
theme: state.settingsReducer.theme,
reader: state.settingsReducer.reader,
});
export default connect(mapStateToProps, {
updateReaderTextSize,
updateReaderTheme,
updateReaderTextAlign,
updateReaderPadding,
setReaderFont,
})(ChapterBottomSheet);
const styles = StyleSheet.create({
contentContainer: {
flex: 1,
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
backgroundColor: "rgba(0,0,0,0.4)",
},
readerSettingsContainer: { flex: 1, alignItems: "center", paddingTop: 30 },
});
+139
View File
@@ -0,0 +1,139 @@
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";
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 [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 = () => {
fetch(getSourceUrl())
.then((response) => response.json())
.then((json) => {
setNovels(json);
setLoading(false);
});
};
const clearSearchbar = () => {
getNovels();
setLoading(true);
setSearchText("");
};
const getSearchResults = () => {
setLoading(true);
fetch(getSearchUrl())
.then((response) => response.json())
.then((json) => {
setNovels(json);
setLoading(false);
});
};
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 = () =>
novels.length === 0 && (
<EmptyView icon="(; ̄Д ̄)" description="No results found" />
);
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,
},
});
+87
View File
@@ -0,0 +1,87 @@
import React, { useEffect } from "react";
import { StyleSheet, View, FlatList, ActivityIndicator } from "react-native";
import { Appbar } from "../../Components/Appbar";
import HistoryCard from "./components/HistoryCard";
import { connect } from "react-redux";
import EmptyView from "../../Components/EmptyView";
import {
getHistoryAction,
deleteHistoryAction,
} from "../../redux/history/history.actions";
const History = ({
navigation,
theme,
history,
loading,
getHistoryAction,
deleteHistoryAction,
}) => {
useEffect(() => {
getHistoryAction();
}, [getHistoryAction]);
const renderHistoryCard = ({ item }) => (
<HistoryCard
item={item}
theme={theme}
deleteHistoryAction={deleteHistoryAction}
navigation={navigation}
/>
);
return (
<>
<Appbar title="History" />
<View
style={[
styles.container,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<FlatList
contentContainerStyle={{ flexGrow: 1, padding: 8 }}
data={history}
keyExtractor={(item) => item.novelUrl.toString()}
renderItem={renderHistoryCard}
ListFooterComponent={
loading && (
<ActivityIndicator
size="small"
color={theme.colorAccent}
/>
)
}
ListEmptyComponent={
!loading && (
<EmptyView
icon="(˘・_・˘)"
description="Nothing read recently"
/>
)
}
/>
</View>
</>
);
};
const mapStateToProps = (state) => ({
theme: state.settingsReducer.theme,
history: state.historyReducer.history,
loading: state.historyReducer.loading,
});
export default connect(mapStateToProps, {
getHistoryAction,
deleteHistoryAction,
})(History);
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
@@ -5,50 +5,31 @@ import { TouchableRipple, IconButton } from "react-native-paper";
import moment from "moment";
import { useSelector } from "react-redux";
const HistoryCard = ({ item, deleteHistory, navigation }) => {
const theme = useSelector((state) => state.themeReducer.theme);
import { useDispatch } from "react-redux";
import { setNovel } from "../../../redux/novel/novel.actions";
const HistoryCard = ({ item, deleteHistoryAction, navigation, theme }) => {
const dispatch = useDispatch();
return (
<TouchableRipple
style={[
styles.historyCard,
// { backgroundColor: theme.colorDarkPrimary },
]}
rippleColor={theme.rippleColorDark}
style={styles.historyCard}
rippleColor={theme.rippleColor}
borderless
onPress={() =>
navigation.navigate("NovelItem", {
novelName: item.novelName,
novelCover: item.novelCover,
extensionId: item.extensionId,
novelUrl: item.novelUrl,
navigatingFrom: 0,
})
}
onPress={() => {
navigation.navigate("Novel", item);
dispatch(setNovel(item));
}}
>
<>
<Image
source={{ uri: item.novelCover }}
style={{
height: 80,
width: 57,
borderRadius: 4,
}}
style={styles.historyCover}
/>
<View
style={{
marginLeft: 15,
flex: 1,
flexDirection: "row",
justifyContent: "space-between",
}}
>
<View style={styles.historyText}>
<View style={{ flex: 0.9 }}>
<Text
style={{
color: theme.textColorPrimaryDark,
color: theme.textColorPrimary,
fontWeight: "bold",
fontSize: 16,
}}
@@ -58,13 +39,13 @@ const HistoryCard = ({ item, deleteHistory, navigation }) => {
</Text>
<Text
style={{
color: theme.textColorSecondaryDark,
color: theme.textColorSecondary,
marginTop: 2,
}}
numberOfLines={1}
>
{`${item.chapterName} - ${moment(
item.lastRead
item.historyTimeRead
).calendar()}`}
</Text>
</View>
@@ -77,24 +58,24 @@ const HistoryCard = ({ item, deleteHistory, navigation }) => {
<IconButton
icon="delete-outline"
size={22}
color={theme.textColorPrimaryDark}
color={theme.textColorPrimary}
style={{
marginRight: 0,
}}
onPress={() => deleteHistory(item.novelUrl)}
onPress={() => deleteHistoryAction(item.novelId)}
/>
<IconButton
icon="play"
size={24}
color={theme.textColorPrimaryDark}
color={theme.textColorPrimary}
style={{ marginRight: 10 }}
onPress={() =>
navigation.navigate("ChapterItem", {
navigation.navigate("Chapter", {
chapterId: item.historyChapterId,
chapterUrl: item.chapterUrl,
extensionId: item.extensionId,
extensionId: item.sourceId,
novelUrl: item.novelUrl,
novelName: item.novelName,
novelCover: item.novelCover,
novelId: item.novelId,
chapterName: item.chapterName,
})
}
@@ -110,13 +91,20 @@ export default HistoryCard;
const styles = StyleSheet.create({
historyCard: {
// backgroundColor: theme.colorDarkPrimary,
// paddingVertical: 10,
// marginVertical: 5,
// paddingHorizontal: 20,
marginBottom: 15,
borderRadius: 4,
flexDirection: "row",
alignItems: "center",
},
historyCover: {
height: 80,
width: 57,
borderRadius: 4,
},
historyText: {
marginLeft: 15,
flex: 1,
flexDirection: "row",
justifyContent: "space-between",
},
});
+126
View File
@@ -0,0 +1,126 @@
import React, { useState, useEffect } from "react";
import {
StyleSheet,
Text,
View,
RefreshControl,
ActivityIndicator,
} from "react-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 { useTheme } from "../../Hooks/reduxHooks";
import { setNovel } from "../../redux/novel/novel.actions";
const LibraryScreen = ({ navigation }) => {
const theme = useTheme();
const dispatch = useDispatch();
const { loading, novels } = useSelector((state) => state.libraryReducer);
const [refreshing, setRefreshing] = useState(false);
const [searchText, setSearchText] = useState("");
useEffect(() => {
dispatch(getLibraryAction());
}, [getLibraryAction]);
const onRefresh = () => {
setRefreshing(true);
dispatch(updateLibraryAction());
setRefreshing(false);
};
const clearSearchbar = () => {
dispatch(getLibraryAction());
setSearchText("");
};
const onChangeText = (text) => {
setSearchText(text);
dispatch(searchLibraryAction(text));
};
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}
/>
);
const listEmptyComponent = () =>
searchText !== "" ? (
<Text style={[styles.emptySearch, { color: theme.colorAccent }]}>
{`"${searchText}" not in library`}
</Text>
) : (
<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"
searchText={searchText}
clearSearchbar={clearSearchbar}
onChangeText={onChangeText}
left="magnify"
theme={theme}
/>
{loading ? (
<ActivityIndicator size="small" color={theme.colorAccent} />
) : (
<NovelList
data={novels}
renderItem={renderItem}
refreshControl={refreshControl()}
ListEmptyComponent={listEmptyComponent}
/>
)}
</View>
</>
);
};
export default LibraryScreen;
const styles = StyleSheet.create({
container: {
flex: 1,
},
emptySearch: {
marginTop: 8,
textAlign: "center",
paddingHorizontal: 16,
},
});
+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.17"
theme={theme}
/>
<ListItem
title="Build Time"
description="06-05-21 21:00 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="Extensions"
description="https://github.com/rajarsheechatterjee/lnreader-extensions"
onPress={() =>
Linking.openURL(
"https://github.com/rajarsheechatterjee/lnreader-extensions"
)
}
theme={theme}
/>
</ListSection>
</View>
</>
);
};
export default AboutScreen;
+74
View File
@@ -0,0 +1,74 @@
import React from "react";
import { StyleSheet, View, Image } from "react-native";
import { useSelector } from "react-redux";
import { Appbar } from "../../Components/Appbar";
import { ListItem, ListSection } from "../../Components/List";
const MoreScreen = ({ navigation }) => {
const theme = useSelector((state) => state.settingsReducer.theme);
return (
<>
<Appbar title="More" style={{ elevation: 0 }} />
<View style={{ flex: 1, backgroundColor: theme.colorPrimaryDark }}>
<View style={{ overflow: "hidden", paddingBottom: 4 }}>
<View
style={{
paddingTop: 20,
paddingBottom: 30,
alignItems: "center",
backgroundColor: theme.colorPrimary,
elevation: 4,
}}
>
<Image
source={require("../../../assets/logo.png")}
style={{
height: 60,
width: 60,
tintColor: theme.textColorPrimary,
}}
/>
</View>
</View>
<ListSection>
<ListItem
title="Settings"
icon="cog-outline"
onPress={() =>
navigation.navigate("MoreStack", {
screen: "Settings",
})
}
theme={theme}
/>
<ListItem
title="Tracking"
icon="sync"
onPress={() =>
navigation.navigate("MoreStack", {
screen: "Trackers",
})
}
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({});
+166
View File
@@ -0,0 +1,166 @@
import React, { useState } from "react";
import { View } from "react-native";
import { connect, useDispatch } from "react-redux";
import { switchTheme } from "../../redux/settings/settings.actions";
import {
setDisplayMode,
setItemsPerRow,
} from "../../redux/settings/settings.actions";
import { deleteNovelCache } from "../../Database/queries/NovelQueries";
import { Appbar } from "../../Components/Appbar";
import {
Divider,
ListItem,
ListSection,
ListSubHeader,
InfoItem,
} from "../../Components/List";
import DisplayModeModal from "./components/DisplayModeModal";
import GridSizeModal from "./components/GridSizeModal";
import ThemeModal from "./components/ThemeModal";
import { createBackup, restoreBackup } from "../../Services/backup";
import { useTheme } from "../../Hooks/reduxHooks";
import { restoreLibraryAction } from "../../redux/library/library.actions";
import { clearAllHistoryAction } from "../../redux/history/history.actions";
const SettingsScreen = ({
navigation,
displayMode,
itemsPerRow,
switchTheme,
setDisplayMode,
setItemsPerRow,
}) => {
const displayModeLabel = (displayMode) => {
const label = {
0: "Compact Grid",
1: "Comfortable Grid",
2: "List",
};
return label[displayMode];
};
const theme = useTheme();
const dispatch = useDispatch();
// 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);
return (
<>
<Appbar title="Settings" onBackAction={() => navigation.goBack()} />
<View
style={{
flex: 1,
backgroundColor: theme.colorPrimaryDark,
paddingVertical: 8,
}}
>
<ListSection>
<ListSubHeader theme={theme}>Data</ListSubHeader>
<ListItem
title="Clear database"
description="Delete history for novels not in your library"
onPress={() => deleteNovelCache()}
theme={theme}
/>
<ListItem
title="Clear history"
description="Delete reading history for all novels"
onPress={() => dispatch(clearAllHistoryAction())}
theme={theme}
/>
<Divider theme={theme} />
<ListSubHeader theme={theme}>Display</ListSubHeader>
<ListItem
title="Display Mode"
description={displayModeLabel(displayMode)}
onPress={showDisplayModal}
theme={theme}
/>
<ListItem
title="Items per row in library"
description={`${itemsPerRow} 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}
/>
<Divider theme={theme} />
<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>
<DisplayModeModal
displayMode={displayMode}
displayModalVisible={displayModalVisible}
hideDisplayModal={hideDisplayModal}
setDisplayMode={setDisplayMode}
theme={theme}
/>
<GridSizeModal
itemsPerRow={itemsPerRow}
gridSizeModalVisible={gridSizeModalVisible}
hideGridSizeModal={hideGridSizeModal}
setItemsPerRow={setItemsPerRow}
theme={theme}
/>
<ThemeModal
themeModalVisible={themeModalVisible}
hidethemeModal={hidethemeModal}
switchTheme={switchTheme}
theme={theme}
/>
</View>
</>
);
};
const mapStateToProps = (state) => ({
displayMode: state.settingsReducer.displayMode,
itemsPerRow: state.settingsReducer.itemsPerRow,
});
export default connect(mapStateToProps, {
switchTheme,
setDisplayMode,
setItemsPerRow,
})(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,59 @@
import React from "react";
import { StyleSheet } from "react-native";
import { Portal, Modal, Checkbox } from "react-native-paper";
const DisplayModeModal = ({
displayMode,
displayModalVisible,
setDisplayMode,
hideDisplayModal,
theme,
}) => {
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={() => setDisplayMode(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,96 @@
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";
const GridSizeModal = ({
itemsPerRow,
gridSizeModalVisible,
hideGridSizeModal,
setItemsPerRow,
theme,
}) => {
const gridSizes = {
1: "XS",
2: "S",
3: "M",
4: "L",
5: "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 },
]}
>
{`${itemsPerRow} per row`}
</Text>
<Slider
style={styles.slider}
value={itemsPerRow}
minimumValue={1}
maximumValue={5}
step={1}
minimumTrackTintColor={theme.colorAccent}
maximumTrackTintColor="#000000"
thumbTintColor={theme.colorAccent}
onValueChange={(value) => setItemsPerRow(value)}
/>
{/* <RadioButtonGroup
onValueChange={(value) => setItemsPerRow(value)}
value={itemsPerRow}
>
{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,
},
});
+61
View File
@@ -0,0 +1,61 @@
import React from "react";
import { StyleSheet } from "react-native";
import { Portal, Modal, Checkbox } from "react-native-paper";
import { setStatusBarStyle } from "expo-status-bar";
const ThemeModal = ({
themeModalVisible,
hidethemeModal,
switchTheme,
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" },
];
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={() => {
switchTheme(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,
},
});
+146
View File
@@ -0,0 +1,146 @@
import React, { useEffect, useRef, useState } from "react";
import { StyleSheet, View, FlatList, RefreshControl } from "react-native";
import { Provider, Portal } from "react-native-paper";
import {
useContinueReading,
useNovel,
usePreferences,
useTheme,
} from "../../Hooks/reduxHooks";
import ChapterItem from "./components/ChapterItem";
import NovelInfoHeader from "./components/NovelHeader";
import ChaptersSettingsSheet from "./components/ChaptersSettingsSheet";
import { useDispatch } from "react-redux";
import {
getNovelAction,
sortAndFilterChapters,
updateNovelAction,
} from "../../redux/novel/novel.actions";
import TrackSheet from "./components/Tracker/TrackSheet";
import { showToast } from "../../Hooks/showToast";
const Novel = ({ route, navigation }) => {
const item = route.params;
const {
sourceId,
novelUrl,
novelName,
novelCover,
followed,
novelId,
} = item;
const theme = useTheme();
const dispatch = useDispatch();
const { novel, chapters, loading, fetching } = useNovel();
let chaptersSettingsSheetRef = useRef(null);
let trackerSheetRef = useRef(null);
const { sort, filter } = usePreferences(
novelId || (!loading && novel.novelId)
);
let lastRead = useContinueReading(
chapters,
novelId || (!loading && novel.novelId)
);
useEffect(() => {
dispatch(
getNovelAction(followed, sourceId, novelUrl, novelId, sort, filter)
);
}, [getNovelAction]);
const onRefresh = () => {
dispatch(updateNovelAction(sourceId, novelUrl, novelId));
showToast(`Updated ${novelName}`);
};
const refreshControl = () => (
<RefreshControl
onRefresh={onRefresh}
refreshing={fetching}
colors={[theme.textColorPrimary]}
progressBackgroundColor={theme.colorPrimary}
/>
);
const ListHeaderComponent = () => (
<NovelInfoHeader
item={item}
novel={novel}
theme={theme}
filter={filter}
loading={loading}
lastRead={lastRead}
dispatch={dispatch}
chapters={chapters}
navigation={navigation}
trackerSheetRef={trackerSheetRef}
chaptersSettingsSheetRef={chaptersSettingsSheetRef}
/>
);
const renderItem = ({ item }) => (
<ChapterItem
theme={theme}
chapter={item}
novelUrl={novelUrl}
dispatch={dispatch}
sourceId={sourceId}
navigation={navigation}
/>
);
return (
<Provider>
<View
style={[
styles.container,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<FlatList
data={!loading && chapters}
keyExtractor={(item) => item.chapterId.toString()}
removeClippedSubviews={true}
maxToRenderPerBatch={5}
windowSize={15}
initialNumToRender={7}
renderItem={renderItem}
ListHeaderComponent={ListHeaderComponent}
refreshControl={refreshControl()}
/>
{!loading && (
<Portal>
<ChaptersSettingsSheet
novelUrl={novelUrl}
bottomSheetRef={chaptersSettingsSheetRef}
dispatch={dispatch}
sortAndFilterChapters={sortAndFilterChapters}
novelId={novelId || novel.novelId}
savedSort={sort}
savedFilter={filter}
/>
<TrackSheet
bottomSheetRef={trackerSheetRef}
novelId={novelId || novel.novelId}
novelName={novelName || novel.novelName}
theme={theme}
/>
</Portal>
)}
</View>
</Provider>
);
};
export default Novel;
const styles = StyleSheet.create({
container: { flex: 1 },
});
+151
View File
@@ -0,0 +1,151 @@
import React, { useState } from "react";
import { View, Text, ActivityIndicator, StyleSheet } from "react-native";
import { TouchableRipple, IconButton, Menu } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import {
deleteChapterAction,
downloadChapterAction,
} from "../../../redux/novel/novel.actions";
const ChapterItem = ({
novelUrl,
chapter,
sourceId,
dispatch,
theme,
navigation,
}) => {
const [visible, setVisible] = useState(false);
const openMenu = () => setVisible(true);
const closeMenu = () => setVisible(false);
const displayDownloadButton = () => {
if (chapter.downloaded === 3) {
return (
<ActivityIndicator
color={theme.textColorHint}
size={25}
style={{ margin: 3.5, padding: 5 }}
/>
);
} else if (chapter.downloaded === 1) {
return (
<Menu
visible={visible}
onDismiss={closeMenu}
anchor={
<IconButton
icon="check-circle"
animated
color={theme.textColorPrimary}
size={25}
onPress={openMenu}
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 }}
/>
);
}
};
return (
<TouchableRipple
style={styles.chapterCardContainer}
onPress={() =>
navigation.navigate("Chapter", {
chapterId: chapter.chapterId,
chapterUrl: chapter.chapterUrl,
extensionId: sourceId,
novelUrl: novelUrl,
chapterName: chapter.chapterName,
novelId: chapter.novelId,
})
}
rippleColor={theme.rippleColor}
>
<>
<View>
<Text
style={[
{
color: theme.textColorPrimary,
},
chapter.read === 1 && {
color: theme.textColorHint,
},
]}
numberOfLines={1}
>
{chapter.chapterName.substring(0, 50)}
</Text>
{chapter.releaseDate && (
<Text
style={[
{
color: theme.textColorSecondary,
marginTop: 5,
fontSize: 12,
},
chapter.read === 1 && {
color: theme.textColorHint,
},
]}
numberOfLines={1}
>
{chapter.releaseDate}
</Text>
)}
</View>
<View>{displayDownloadButton()}</View>
</>
</TouchableRipple>
);
};
export default ChapterItem;
const styles = StyleSheet.create({
chapterCardContainer: {
paddingHorizontal: 15,
paddingVertical: 7,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
});
@@ -1,36 +1,40 @@
import React from "react";
import { StyleSheet, View, Text } from "react-native";
import React, { useState } from "react";
import { StyleSheet, View, Text, Animated } from "react-native";
import { List, RadioButton, Checkbox } from "react-native-paper";
import Bottomsheet from "rn-sliding-up-panel";
import { useSelector } from "react-redux";
const BottomSheetHandle = () => (
<View
style={{
backgroundColor: "rgba(255,255,255,0.25)",
height: 4,
width: 50,
borderRadius: 50,
top: 10,
alignSelf: "center",
}}
/>
);
export const BottomSheet = ({
const ChaptersSettingsSheet = ({
bottomSheetRef,
sort,
filter,
sortChapters,
filterChapters,
novelId,
sortAndFilterChapters,
dispatch,
savedSort,
savedFilter,
}) => {
const [sort, setSort] = useState(savedSort || "ORDER BY chapterId ASC");
const [filter, setFilter] = useState(savedFilter || "");
const sortChapters = (val) => {
setSort(val);
dispatch(sortAndFilterChapters(novelId, val, filter));
};
const filterChapters = (val) => {
setFilter(val);
dispatch(sortAndFilterChapters(novelId, sort, val));
};
const checkBoxStyle = { flexDirection: "row", alignItems: "center" };
const theme = useSelector((state) => state.themeReducer.theme);
const theme = useSelector((state) => state.settingsReducer.theme);
const [animatedValue] = useState(new Animated.Value(0));
return (
<Bottomsheet
animatedValue={animatedValue}
ref={bottomSheetRef}
draggableRange={{ top: 370, bottom: 0 }}
snappingPoints={[0, 370]}
@@ -39,14 +43,14 @@ export const BottomSheet = ({
style={[
styles.contentContainer,
{
backgroundColor: theme.colorDarkPrimary,
backgroundColor: theme.colorPrimary,
},
]}
>
<BottomSheetHandle />
<View style={styles.bottomSheetHandle} />
<List.Subheader
style={{
color: theme.textColorPrimaryDark,
color: theme.textColorPrimary,
fontSize: 20,
}}
>
@@ -54,9 +58,10 @@ export const BottomSheet = ({
</List.Subheader>
<View>
<RadioButton.Group
onValueChange={(newValue) => sortChapters(newValue)}
onValueChange={(newValue) =>
sortChapters(newValue, filter)
}
value={sort}
c
>
<View
style={{
@@ -71,13 +76,13 @@ export const BottomSheet = ({
}}
>
<RadioButton.Item
uncheckedColor={theme.textColorHintDark}
color={theme.colorAccentDark}
uncheckedColor={theme.textColorHint}
color={theme.colorAccent}
value="ORDER BY chapterId DESC"
/>
<Text
style={{
color: theme.textColorPrimaryDark,
color: theme.textColorPrimary,
}}
>
Newest to oldest
@@ -90,13 +95,13 @@ export const BottomSheet = ({
}}
>
<RadioButton.Item
uncheckedColor={theme.textColorHintDark}
color={theme.colorAccentDark}
value=""
uncheckedColor={theme.textColorHint}
color={theme.colorAccent}
value="ORDER BY chapterId ASC"
/>
<Text
style={{
color: theme.textColorPrimaryDark,
color: theme.textColorPrimary,
}}
>
Oldest to newest
@@ -108,7 +113,7 @@ export const BottomSheet = ({
<List.Subheader
style={{
color: theme.textColorPrimaryDark,
color: theme.textColorPrimary,
fontSize: 20,
paddingVertical: 10,
}}
@@ -118,11 +123,11 @@ export const BottomSheet = ({
<View style={checkBoxStyle}>
<Checkbox.Item
status={filter === "" ? "checked" : "unchecked"}
uncheckedColor={theme.textColorHintDark}
color={theme.colorAccentDark}
uncheckedColor={theme.textColorHint}
color={theme.colorAccent}
onPress={() => filterChapters("")}
/>
<Text style={{ color: theme.textColorPrimaryDark }}>
<Text style={{ color: theme.textColorPrimary }}>
Show all
</Text>
</View>
@@ -131,11 +136,11 @@ export const BottomSheet = ({
status={
filter === "AND `read`=1" ? "checked" : "unchecked"
}
uncheckedColor={theme.textColorHintDark}
color={theme.colorAccentDark}
uncheckedColor={theme.textColorHint}
color={theme.colorAccent}
onPress={() => filterChapters("AND `read`=1")}
/>
<Text style={{ color: theme.textColorPrimaryDark }}>
<Text style={{ color: theme.textColorPrimary }}>
Show read chapters
</Text>
</View>
@@ -144,11 +149,11 @@ export const BottomSheet = ({
status={
filter === "AND `read`=0" ? "checked" : "unchecked"
}
uncheckedColor={theme.textColorHintDark}
color={theme.colorAccentDark}
uncheckedColor={theme.textColorHint}
color={theme.colorAccent}
onPress={() => filterChapters("AND `read`=0")}
/>
<Text style={{ color: theme.textColorPrimaryDark }}>
<Text style={{ color: theme.textColorPrimary }}>
Show unread chapters
</Text>
</View>
@@ -159,11 +164,11 @@ export const BottomSheet = ({
? "checked"
: "unchecked"
}
uncheckedColor={theme.textColorHintDark}
color={theme.colorAccentDark}
uncheckedColor={theme.textColorHint}
color={theme.colorAccent}
onPress={() => filterChapters("AND downloaded=1")}
/>
<Text style={{ color: theme.textColorPrimaryDark }}>
<Text style={{ color: theme.textColorPrimary }}>
Show downloaded chapters
</Text>
</View>
@@ -172,8 +177,20 @@ export const BottomSheet = ({
);
};
export default ChaptersSettingsSheet;
const styles = StyleSheet.create({
contentContainer: {
flex: 1,
borderTopRightRadius: 8,
borderTopLeftRadius: 8,
},
bottomSheetHandle: {
backgroundColor: "rgba(255,255,255,0.25)",
height: 4,
width: 50,
borderRadius: 50,
top: 10,
alignSelf: "center",
},
});
@@ -0,0 +1,53 @@
import React from "react";
import { StyleSheet } from "react-native";
import { Chip } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useDispatch } from "react-redux";
import { followNovelAction } from "../../../../redux/novel/novel.actions";
const FollowChip = ({ followed, novel, theme }) => {
const dispatch = useDispatch();
const getIconName = () => (followed ? "heart" : "heart-outline");
const getChipLabel = () => (followed ? "In Library" : "Add to library");
return (
<Chip
mode="outlined"
icon={() => (
<MaterialCommunityIcons
name={getIconName()}
size={21}
color={theme.colorAccent}
/>
)}
onPress={() => dispatch(followNovelAction(novel))}
style={[
{ backgroundColor: theme.colorPrimaryDark },
styles.toggleFavourite,
]}
textStyle={{
fontWeight: "bold",
color: theme.textColorPrimary,
}}
>
{getChipLabel()}
</Chip>
);
};
export default FollowChip;
const styles = StyleSheet.create({
toggleFavourite: {
height: 32,
justifyContent: "center",
alignItems: "center",
marginLeft: 16,
paddingLeft: 4,
borderWidth: 0,
elevation: 0,
},
});
@@ -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,36 @@
import React from "react";
import { ImageBackground, StyleSheet } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import easeGradient from "react-native-easing-gradient";
const NovelCoverImage = ({ 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.background}>
<LinearGradient
colors={colors}
locations={locations}
style={styles.linearGradient}
>
{children}
</LinearGradient>
</ImageBackground>
);
};
export default NovelCoverImage;
const styles = StyleSheet.create({
background: { height: 285 },
linearGradient: { height: 286 },
});
@@ -0,0 +1,66 @@
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.textColorPrimary },
styles.summaryHeader,
]}
>
About
</Text>
<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,
marginBottom: 8,
},
summaryHeader: {
marginTop: 5,
paddingVertical: 5,
fontSize: 15,
fontWeight: "bold",
},
showMoreButton: {
fontWeight: "bold",
position: "absolute",
bottom: 0,
right: 18,
paddingLeft: 6,
},
});
@@ -0,0 +1,56 @@
import React from "react";
import { StyleSheet } from "react-native";
import { Button } from "react-native-paper";
import { useContinueReading } from "../../../../Hooks/reduxHooks";
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,
extensionId: novel.sourceId,
chapterName: lastRead.chapterName,
});
}}
>
{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,
},
});
@@ -0,0 +1,48 @@
import React from "react";
import { StyleSheet } from "react-native";
import { Chip } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons";
const TrackerChip = ({ isTracked, trackerSheetRef, theme }) => {
const getIconName = () => (isTracked ? "check" : "sync");
const getChipLabel = () => (isTracked ? "Tracked" : "Tracking");
return (
<Chip
mode="outlined"
icon={() => (
<MaterialCommunityIcons
name={getIconName(isTracked)}
size={21}
color={theme.colorAccent}
/>
)}
onPress={() => trackerSheetRef.current.show()}
style={[
styles.toggleFavourite,
{ backgroundColor: theme.colorPrimaryDark },
]}
textStyle={{
fontWeight: "bold",
color: theme.textColorPrimary,
}}
>
{getChipLabel()}
</Chip>
);
};
export default TrackerChip;
const styles = StyleSheet.create({
toggleFavourite: {
height: 32,
justifyContent: "center",
alignItems: "center",
paddingLeft: 4,
marginLeft: 0,
borderWidth: 0,
elevation: 0,
},
});
+280
View File
@@ -0,0 +1,280 @@
import React, { useState } from "react";
import { View, Text, StyleSheet, Image, FlatList, Share } from "react-native";
import { TouchableRipple, IconButton, Button, Menu } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import * as WebBrowser from "expo-web-browser";
import { useSelector, useDispatch } from "react-redux";
import {
downloadAllChaptersAction,
deleteAllChaptersAction,
} from "../../../redux/novel/novel.actions";
import { GenreChip } from "./Info/GenreChip";
import NovelCoverImage from "./Info/NovelCoverImage";
import FollowChip from "./Info/FollowChip";
import TrackerChip from "./Info/TrackerChip";
import NovelSummary from "./Info/NovelSummary";
import ReadButton from "./Info/ReadButton";
const NovelInfoHeader = ({
item,
novel,
theme,
filter,
loading,
dispatch,
chapters,
lastRead,
navigation,
trackerSheetRef,
chaptersSettingsSheetRef,
}) => {
const [downloadMenu, showDownloadMenu] = useState(false);
const tracker = useSelector((state) => state.trackerReducer.tracker);
const trackedNovels = useSelector(
(state) => state.trackerReducer.trackedNovels
);
let isTracked = false;
if (!loading) {
isTracked = trackedNovels.find((obj) => obj.novelId === novel.novelId);
}
const getGenres = ({ item }) => <GenreChip theme={theme}>{item}</GenreChip>;
return (
<View style={{ flexGrow: 1 }}>
<NovelCoverImage source={{ uri: item.novelCover }} theme={theme}>
<View style={styles.detailsContainer}>
<View>
<Image
source={{ uri: item.novelCover }}
style={styles.logo}
/>
</View>
<View style={styles.nameContainer}>
<Text
numberOfLines={2}
style={[
styles.name,
{ color: theme.textColorPrimary },
]}
>
{item.novelName}
</Text>
<>
<Text
style={{
color: theme.textColorSecondary,
marginVertical: 3,
fontSize: 14,
fontWeight: "bold",
}}
numberOfLines={2}
>
{(item.author &&
item.author.replace(",", ", ")) ||
(!loading &&
novel.author.replace(",", ", "))}
</Text>
<Text
style={{
color: theme.textColorSecondary,
marginVertical: 3,
fontSize: 14,
}}
numberOfLines={1}
>
{item.status || (!loading && novel.status)}
</Text>
<Text
style={{
color: theme.textColorSecondary,
fontSize: 14,
}}
numberOfLines={1}
>
{item.source || (!loading && novel.source)}
</Text>
</>
</View>
</View>
</NovelCoverImage>
{!loading && (
<>
<View style={styles.buttonsContainer}>
<FollowChip
theme={theme}
followed={novel.followed}
novel={novel}
/>
{tracker && (
<TrackerChip
theme={theme}
isTracked={isTracked}
trackerSheetRef={trackerSheetRef}
/>
)}
<IconButton
onPress={() =>
WebBrowser.openBrowserAsync(novel.sourceUrl)
}
icon="earth"
color={theme.colorAccent}
size={21}
/>
<IconButton
onPress={() =>
Share.share({ message: novel.sourceUrl })
}
icon="share-variant"
color={theme.colorAccent}
size={21}
/>
<Menu
visible={downloadMenu}
onDismiss={() => showDownloadMenu(false)}
anchor={
<IconButton
icon="download"
color={theme.colorAccent}
size={21}
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>
</View>
<NovelSummary
summary={novel.novelSummary}
followed={novel.followed}
theme={theme}
/>
<FlatList
style={
novel.novelSummary === ""
? { marginTop: 20 }
: { marginTop: 12 }
}
contentContainerStyle={styles.genreContainer}
horizontal
data={novel.genre.split(",")}
keyExtractor={(item) => item}
renderItem={getGenres}
showsHorizontalScrollIndicator={false}
/>
<ReadButton
novel={novel}
chapters={chapters}
navigation={navigation}
theme={theme}
lastRead={lastRead}
/>
<TouchableRipple
style={styles.bottomsheet}
onPress={() =>
chaptersSettingsSheetRef.current.show({
velocity: -1.5,
})
}
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({
nameContainer: {
flex: 1,
marginHorizontal: 16,
paddingTop: 8,
},
detailsContainer: {
flex: 1,
flexDirection: "row",
margin: 16,
paddingTop: 90,
},
logo: {
height: 160,
width: 110,
margin: 3.2,
borderRadius: 6,
},
name: {
fontWeight: "bold",
fontSize: 18,
},
buttonsContainer: {
flexDirection: "row",
alignItems: "center",
},
chapters: {
paddingHorizontal: 16,
paddingVertical: 4,
fontSize: 16,
fontWeight: "bold",
},
bottomsheet: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingRight: 12,
},
genreContainer: {
paddingHorizontal: 15,
paddingBottom: 15,
},
});
@@ -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 },
});
+95
View File
@@ -0,0 +1,95 @@
import React, { useState, useEffect } from "react";
import {
StyleSheet,
View,
FlatList,
ActivityIndicator,
RefreshControl,
} from "react-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";
const Updates = ({ navigation }) => {
const theme = useTheme();
const dispatch = useDispatch();
const { updates, loading } = useSelector((state) => state.updatesReducer);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
dispatch(getUpdatesAction());
}, [getUpdatesAction, updates]);
const onRefresh = async () => {
setRefreshing(true);
dispatch(updateLibraryAction());
setRefreshing(false);
};
const onPress = (item) =>
navigation.navigate("Chapter", {
chapterId: item.chapterId,
chapterUrl: item.chapterUrl,
extensionId: item.sourceId,
novelUrl: item.novelUrl,
chapterName: item.chapterName,
novelId: item.novelId,
});
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}
/>
);
return (
<>
<Appbar theme={theme} dispatch={dispatch} />
<View
style={[
styles.container,
{ backgroundColor: theme.colorPrimaryDark },
]}
>
<FlatList
contentContainerStyle={styles.flatList}
data={updates}
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";
import moment from "moment";
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,
}}
numberOfLines={1}
>
{item.novelName}
</Text>
<View style={{ flexDirection: "row" }}>
<Text
style={{
color: theme.textColorPrimary,
fontSize: 12,
}}
>
{`${item.chapterName} · ${moment(
item.updateTime
).calendar()}`}
</Text>
</View>
</View>
</>
</TouchableRipple>
);
};
export default UpdateCard;
const styles = StyleSheet.create({
updateCard: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 12,
},
updateIcon: {
width: 42,
height: 42,
borderRadius: 4,
},
chapterDetails: {
flex: 1,
marginLeft: 16,
},
});
+53
View File
@@ -0,0 +1,53 @@
export const fetchSources = async () => {
const url = `https://lnreader-extensions.vercel.app/api/`;
let res = await fetch(url);
let sources = await res.json();
return sources;
};
export const fetchNovel = 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 novel = {
novelUrl: res.novelUrl,
sourceUrl: res.sourceUrl,
source: res.sourceName,
sourceId: res.extensionId,
novelName: res.novelName,
novelCover: res.novelCover,
novelSummary: res.novelSummary,
author: res["Author(s)"],
artist: res["Artist(s)"],
status: res.Status,
genre: res["Genre(s)"],
followed: 0,
chapters: res.novelChapters,
};
return novel;
};
export const fetchChapter = async (sourceId, 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);
}
}
};
+44
View File
@@ -0,0 +1,44 @@
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";
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();
if (backup.uri) {
let novels = await StorageAccessFramework.readAsStringAsync(backup.uri);
novels = await JSON.parse(novels);
await novels.map((novel) => restoreLibrary(novel));
showToast("Restored backup");
}
};
+145
View File
@@ -0,0 +1,145 @@
import * as SQLite from "expo-sqlite";
const db = SQLite.openDatabase("lnreader.db");
import { getLibrary } from "../Database/queries/LibraryQueries";
import { fetchChapters, fetchNovel } from "../Services/Source/source";
import * as Notifications from "expo-notifications";
Notifications.setNotificationHandler({
handleNotification: async () => {
return {
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
};
},
});
const updateNovelDetails = async (novel, novelId) => {
db.transaction((tx) => {
tx.executeSql(
"UPDATE novels SET novelName = ?, novelCover = ?, novelSummary = ?, author = ?, artist = ?, genre = ?, status = ? WHERE novelId = ?",
[
novel.novelName,
novel.novelCover,
novel.novelSummary,
novel.author,
novel.artist,
novel.genre,
novel.status,
novelId,
],
(txObj, res) => {},
(txObj, error) => console.log("Error ", error)
);
});
};
export const updateNovel = async (extensionId, novelUrl, novelId) => {
let novel = await fetchNovel(extensionId, novelUrl);
await updateNovelDetails(novel, novelId);
db.transaction((tx) => {
novel.chapters.map((chapter) =>
tx.executeSql(
"INSERT OR IGNORE INTO chapters (chapterUrl, chapterName, releaseDate, novelId) values (?, ?, ?, ?)",
[
chapter.chapterUrl,
chapter.chapterName,
chapter.releaseDate,
novelId,
],
(txObj, { insertId }) => {
if (insertId !== -1) {
tx.executeSql(
"INSERT OR IGNORE INTO updates (chapterId, novelId, updateTime) values (?, ?, (datetime('now','localtime')))",
[insertId, novelId],
(txObj, res) => {
console.log(
"Inserted Chapter Id -> " +
insertId +
" Novel Id " +
novelId
);
},
(txObj, error) => console.log("Error ", error)
);
}
},
(txObj, error) => console.log("Error ", error)
)
);
});
};
export const updateNovelChapters = async (extensionId, novelUrl, novelId) => {
let chapters = await fetchChapters(extensionId, novelUrl);
db.transaction((tx) => {
chapters.map((chapter) =>
tx.executeSql(
"INSERT OR IGNORE INTO chapters (chapterUrl, chapterName, releaseDate, novelId) values (?, ?, ?, ?)",
[
chapter.chapterUrl,
chapter.chapterName,
chapter.releaseDate,
novelId,
],
(txObj, { insertId }) => {
if (insertId !== -1) {
console.log(
"Inserted Chapter " +
chapter.chapterUrl +
" Chapter Name " +
chapter.chapterName +
" Novel Id " +
novelId
);
tx.executeSql(
"INSERT OR IGNORE INTO updates (chapterId, novelId, updateTime) values (?, ?, (datetime('now','localtime')))",
[insertId, novelId],
(txObj, res) => {},
(txObj, error) => console.log("Error ", error)
);
}
},
(txObj, error) => console.log("Error ", error)
)
);
});
};
export const updateAllNovels = async () => {
const libraryNovels = await getLibrary();
libraryNovels.map((novel, index) =>
setTimeout(async () => {
updateNovelChapters(novel.sourceId, novel.novelUrl, novel.novelId);
console.log(novel.novelName + " Updated");
if (index + 1 === libraryNovels.length) {
Notifications.scheduleNotificationAsync({
content: { title: "Library Updated" },
trigger: null,
});
}
}, 3000 * index)
);
};
export const parseChapterNumber = (chapterName) => {
chapterName = chapterName.toLowerCase();
chapterName = chapterName.replace(/volume (\d+)/, "");
const basic = chapterName.match(/ch (\d+)/);
const occurrence = chapterName.match(/\d+/);
if (basic) {
return basic[0];
} else if (occurrence) {
return occurrence[0];
} else {
return 0;
}
};
+11
View File
@@ -0,0 +1,11 @@
const fonts = [
{ fontFamily: "", name: "Original" },
{ fontFamily: "arbutus-slab", name: "Arbutus Slab" },
{ fontFamily: "domine", name: "Domine" },
{ fontFamily: "lato", name: "Lato" },
{ fontFamily: "noto-sans", name: "Noto Sans" },
{ fontFamily: "open-sans", name: "Open Sans" },
{ fontFamily: "pt-serif", name: "PT Serif" },
];
export { fonts };
+13
View File
@@ -0,0 +1,13 @@
/**
* Reader fonts
*/
export const fonts = {
"pt-sans-bold": require("../../assets/fonts/PTSansNarrow-Bold.ttf"),
"arbutus-slab": require("../../assets/fonts/ArbutusSlab-Regular.ttf"),
domine: require("../../assets/fonts/Domine-VariableFont_wght.ttf"),
lato: require("../../assets/fonts/Lato-Regular.ttf"),
"noto-sans": require("../../assets/fonts/NotoSans-Regular.ttf"),
"open-sans": require("../../assets/fonts/OpenSans-Regular.ttf"),
"pt-serif": require("../../assets/fonts/PTSerif-Regular.ttf"),
};
+119
View File
@@ -0,0 +1,119 @@
/**
* Application Colors
*/
export const theme = {
colorPrimary: "#000000",
colorPrimaryDark: "#000000",
colorAccent: "#3399FF",
textColorPrimary: "#FFFFFF",
textColorSecondary: "rgba(255,255,255,0.7)",
textColorHint: "rgba(255,255,255,0.5)",
rippleColor: "rgba(255,255,255,0.2)",
};
/**
* Light Theme
*/
export const lightTheme = {
id: 1,
name: "Light",
colorPrimary: "#FFFFFF",
colorPrimaryDark: "#FAFAFA",
colorAccent: "#2979FF",
textColorPrimary: "#000000",
textColorSecondary: "rgba(0,0,0,0.54)",
textColorHint: "rgba(0,0,0,0.38)",
rippleColor: "rgba(0,0,0,0.12)",
searchBarColor: "#FFFFFF",
menuColor: "#FFFFFF",
dividerColor: "rgba(0,0,0,0.1)",
filterColor: "#FFC107",
colorDisabled: "rgba(220,220,220,255)",
colorButtonText: "#000000",
statusBar: "dark",
};
/**
* Dark Theme
*/
export const darkTheme = {
id: 2,
name: "Dark",
colorPrimary: "#242529",
colorPrimaryDark: "#202125",
colorAccent: "#3399FF",
textColorPrimary: "#FFFFFF",
textColorSecondary: "rgba(255,255,255,0.7)",
textColorHint: "rgba(255,255,255,0.5)",
rippleColor: "rgba(255,255,255,0.2)",
searchBarColor: "#303135",
menuColor: "#242529",
filterColor: "#FFEB3B",
dividerColor: "rgba(255,255,255,0.1)",
colorDisabled: "rgba(57,57,57,255)",
colorButtonText: "#FFFFFF",
statusBar: "light",
};
/**
* Amoled Dark Theme
*/
export const amoledDarkTheme = {
id: 0,
name: "AMOLED Dark",
colorPrimary: "#000000",
colorPrimaryDark: "#000000",
colorAccent: "#3399FF",
textColorPrimary: "#FFFFFF",
textColorSecondary: "rgba(255,255,255,0.7)",
textColorHint: "rgba(255,255,255,0.5)",
rippleColor: "rgba(255,255,255,0.2)",
searchBarColor: "#1F1F1F",
menuColor: "#242529",
filterColor: "#FFEB3B",
dividerColor: "rgba(255,255,255,0.15)",
colorDisabled: "rgba(33,33,33,255)",
colorButtonText: "#FFFFFF",
statusBar: "light",
};
/**
* Midnight Dusk Theme
*/
export const midnightDuskTheme = {
id: 3,
name: "Midnight Dusk",
colorPrimary: "#201F27",
colorPrimaryDark: "#16151D",
colorAccent: "#F02475",
textColorPrimary: "#FFFFFF",
textColorSecondary: "rgba(255,255,255,0.7)",
textColorHint: "rgba(255,255,255,0.5)",
rippleColor: "rgba(255,255,255,0.2)",
searchBarColor: "#201F27",
menuColor: "#201F27",
filterColor: "#FFEB3B",
dividerColor: "rgba(255,255,255,0.1)",
colorDisabled: "rgba(57,57,57,255)",
colorButtonText: "#FFFFFF",
statusBar: "light",
};
export const limeTheme = {
id: 4,
name: "Lime",
colorPrimary: "#201F27",
colorPrimaryDark: "#16151D",
colorAccent: "#4AF88A",
textColorPrimary: "#FFFFFF",
textColorSecondary: "rgba(255,255,255,0.7)",
textColorHint: "rgba(255,255,255,0.5)",
rippleColor: "rgba(255,255,255,0.2)",
searchBarColor: "#201F27",
menuColor: "#201F27",
filterColor: "#FFEB3B",
dividerColor: "rgba(255,255,255,0.1)",
colorDisabled: "rgba(57,57,57,255)",
colorButtonText: "#16151D",
statusBar: "light",
};
-129
View File
@@ -1,129 +0,0 @@
import React from "react";
import { Animated, StyleSheet, View, Text, Dimensions } from "react-native";
import { ToggleButton } from "react-native-paper";
import Slider from "@react-native-community/slider";
import Bottomsheet from "rn-sliding-up-panel";
import { useSelector } from "react-redux";
import { saveReaderTheme } from "../../services/asyncStorage";
const ChapterBottomSheet = ({
bottomSheetRef,
setSize,
size,
readerTheme,
setReaderTheme,
}) => {
const theme = useSelector((state) => state.themeReducer.theme);
return (
<Bottomsheet
animatedValue={new Animated.Value(0)}
ref={bottomSheetRef}
draggableRange={{ top: 200, bottom: 0 }}
snappingPoints={[0, 200]}
showBackdrop={false}
>
<View
style={[
styles.contentContainer,
{
backgroundColor: "rgba(0,0,0,0.4)",
},
]}
>
<View
style={{
backgroundColor: theme.textColorHintDark,
height: 5,
width: 30,
borderRadius: 50,
top: 10,
alignSelf: "center",
}}
/>
<View style={{ flex: 1, alignItems: "center", paddingTop: 30 }}>
<Text
style={{
color: theme.textColorPrimaryDark,
fontWeight: "bold",
}}
>
Text Size
</Text>
<Slider
style={{
width: Dimensions.get("window").width,
height: 40,
}}
value={size}
minimumValue={12}
maximumValue={20}
step={4}
minimumTrackTintColor={theme.colorAccentDark}
maximumTrackTintColor="#000000"
thumbTintColor={theme.colorAccentDark}
onValueChange={(value) => setSize(value)}
/>
<Text
style={{
color: theme.textColorPrimaryDark,
fontWeight: "bold",
}}
>
Reader Theme
</Text>
<ToggleButton.Row
onValueChange={(value) => {
setReaderTheme(value);
saveReaderTheme(value);
}}
value={readerTheme}
style={{ marginTop: 10 }}
>
<ToggleButton
icon="format-text"
color="white"
value={1}
style={{
backgroundColor: "black",
marginHorizontal: 10,
}}
/>
<ToggleButton
icon="format-text"
color="black"
value={2}
style={{
backgroundColor: "white",
marginHorizontal: 10,
}}
/>
<ToggleButton
icon="format-text"
color="black"
value={3}
style={{
backgroundColor: "#F4ECD8",
marginHorizontal: 10,
}}
/>
</ToggleButton.Row>
</View>
</View>
</Bottomsheet>
);
};
export default ChapterBottomSheet;
const styles = StyleSheet.create({
contentContainer: {
flex: 1,
// paddingTop: ,
// alignItems: "center",
},
});
-19
View File
@@ -1,19 +0,0 @@
import React from "react";
import { Appbar } from "react-native-paper";
import { useSelector } from "react-redux";
export const CustomAppbar = ({ title, onBackAction }) => {
const theme = useSelector((state) => state.themeReducer.theme);
return (
<Appbar.Header style={{ backgroundColor: theme.colorDarkPrimary }}>
{onBackAction && <Appbar.BackAction onPress={onBackAction} />}
<Appbar.Content
title={title}
titleStyle={{ color: theme.textColorPrimaryDark }}
/>
</Appbar.Header>
);
};
-99
View File
@@ -1,99 +0,0 @@
import React, { useState } from "react";
import { StyleSheet, View, Text, ImageBackground } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { TouchableRipple } from "react-native-paper";
import { theme } from "../../theme/theme";
import AsyncStorage from "@react-native-async-storage/async-storage";
const NovelCover = ({ item, onPress, libraryStatus }) => {
const [mode, setMode] = useState();
AsyncStorage.getItem("@display_mode").then((value) => setMode(value));
return (
<View style={{ flex: 1 / 3 }}>
<TouchableRipple
borderless
centered
rippleColor={theme.rippleColorDark}
style={styles.opac}
onPress={onPress}
>
<>
{item.novelCover ? (
<ImageBackground
source={{
uri: item.novelCover,
}}
style={styles.logo}
imageStyle={[
{ borderRadius: 4 },
libraryStatus && { opacity: 0.4 },
]}
progressiveRenderingEnabled={true}
>
{(!mode || mode === "compact") && (
<View style={styles.titleContainer}>
<LinearGradient
colors={[
"transparent",
"rgba(0,0,0,0.6)",
]}
style={styles.linearGradient}
>
<Text
numberOfLines={2}
style={styles.title}
>
{item.novelName}
</Text>
</LinearGradient>
</View>
)}
</ImageBackground>
) : (
<View>
<Text style={{ color: "white" }}>HAHAHAHAH</Text>
</View>
)}
{mode === "comfortable" && (
<Text numberOfLines={2} style={styles.title}>
{item.novelName}
</Text>
)}
</>
</TouchableRipple>
</View>
);
};
export default NovelCover;
const styles = StyleSheet.create({
logo: {
height: 173,
borderRadius: 4,
},
titleContainer: {
flex: 1,
justifyContent: "flex-end",
borderRadius: 4,
},
title: {
fontFamily: "pt-sans-bold",
fontSize: 14,
color: "white",
padding: 8,
textShadowColor: "rgba(0, 0, 0, 0.75)",
textShadowOffset: { width: -1, height: 1 },
textShadowRadius: 10,
},
linearGradient: {
borderRadius: 4,
},
opac: {
paddingHorizontal: 3.5,
paddingVertical: 3.5,
borderRadius: 4,
},
});
-27
View File
@@ -1,27 +0,0 @@
import React from "react";
import { TextInput, StyleSheet } from "react-native";
import { theme } from "../../theme/theme";
const SearchBar = ({ searchText, onChangeText, onSubmitEditing }) => (
<TextInput
autoFocus
placeholder="Search..."
defaultValue={searchText}
style={styles.searchBar}
placeholderTextColor={theme.textColorHintDark}
blurOnSubmit={true}
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
/>
);
export default SearchBar;
const styles = StyleSheet.create({
searchBar: {
flex: 1,
fontSize: 18,
color: "white",
marginLeft: 15,
},
});
-115
View File
@@ -1,115 +0,0 @@
import React, { useState } from "react";
import { View, Text, ActivityIndicator } from "react-native";
import { TouchableRipple, IconButton } from "react-native-paper";
import { useSelector } from "react-redux";
const ChapterCard = ({
navigation,
novelUrl,
chapter,
downloadChapter,
extensionId,
downloading,
}) => {
const [downloaded, setDownloaded] = useState(chapter.downloaded);
const theme = useSelector((state) => state.themeReducer.theme);
return (
<TouchableRipple
style={{
paddingHorizontal: 15,
paddingVertical: 7,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
// borderTopColor: "rgba(255,255,255,.12)",
// borderTopWidth: 1,
}}
onPress={() =>
navigation.navigate("ChapterItem", {
chapterUrl: chapter.chapterUrl,
extensionId,
novelUrl: novelUrl,
chapterName: chapter.chapterName,
})
}
rippleColor={theme.rippleColorDark}
>
<>
<View>
<Text
style={[
{
color: theme.textColorPrimaryDark,
},
chapter.read === 1 && {
color: theme.textColorHintDark,
},
]}
numberOfLines={1}
>
{chapter.chapterName.substring(0, 50)}
</Text>
{chapter.releaseDate && (
<Text
style={[
{
color: theme.textColorSecondaryDark,
marginTop: 5,
fontSize: 12,
},
chapter.read === 1 && {
color: theme.textColorHintDark,
},
]}
numberOfLines={1}
>
{chapter.releaseDate}
</Text>
)}
</View>
<View>
{downloading.downloading &&
downloading.chapterUrl === chapter.chapterUrl ? (
<ActivityIndicator
color={theme.textColorHintDark}
size={25}
style={{ margin: 3.5, padding: 5 }}
/>
) : (
<IconButton
icon={
downloaded
? "check-circle"
: "arrow-down-circle-outline"
}
animated
color={
downloaded
? theme.textColorPrimaryDark
: theme.textColorHintDark
}
size={25}
onPress={() => {
downloadChapter(
downloaded ? 1 : 0,
extensionId,
novelUrl,
chapter.chapterUrl,
chapter.chapterName
);
setDownloaded(!downloaded);
}}
style={{ margin: 2 }}
/>
)}
</View>
</>
</TouchableRipple>
);
};
export default ChapterCard;
-367
View File
@@ -1,367 +0,0 @@
import React, { useState } from "react";
import {
View,
Text,
ImageBackground,
StyleSheet,
Image,
FlatList,
Share,
} from "react-native";
import { TouchableRipple, IconButton, Button, Chip } from "react-native-paper";
import { useNavigation } from "@react-navigation/native";
import * as WebBrowser from "expo-web-browser";
import { LinearGradient } from "expo-linear-gradient";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { connect } from "react-redux";
const NovelInfoHeader = ({
item,
novel,
noOfChapters,
insertNovelInLib,
loading,
navigatingFrom,
libraryStatus,
bottomSheetRef,
theme,
}) => {
const navigation = useNavigation();
const [more, setMore] = useState(navigatingFrom === 2 ? true : false);
const renderGenreChip = ({ item }) => (
<Text
style={[
styles.genre,
{
color: theme.colorAccentDark,
borderColor: theme.colorAccentDark,
backgroundColor: theme.colorDarkPrimary,
},
]}
>
{item}
</Text>
);
return (
<>
<ImageBackground
source={{
uri: item.novelCover,
}}
style={styles.background}
>
<LinearGradient
colors={["rgba(0,0,0,0.2)", theme.colorDarkPrimaryDark]}
style={styles.linearGradient}
>
<View style={styles.detailsContainer}>
<View>
<Image
source={{
uri: item.novelCover,
}}
style={styles.logo}
/>
</View>
<View style={styles.nameContainer}>
<Text
numberOfLines={2}
style={[
styles.name,
{
color: theme.textColorPrimaryDark,
},
]}
>
{item.novelName}
</Text>
{!loading && (
<>
<Text
style={{
color: theme.textColorSecondaryDark,
marginVertical: 3,
fontSize: 14,
fontWeight: "bold",
}}
>
{novel["Author(s)"] &&
novel["Author(s)"].replace(
",",
", "
)}
</Text>
<Text
style={{
color: theme.textColorSecondaryDark,
marginVertical: 3,
fontSize: 14,
}}
numberOfLines={1}
>
{novel["Status"]}
</Text>
<Text
style={{
color: theme.textColorSecondaryDark,
// marginVertical: 3,
fontSize: 14,
}}
numberOfLines={1}
>
{novel.source}
</Text>
</>
)}
</View>
</View>
</LinearGradient>
</ImageBackground>
{!loading && (
<>
<View
style={{
flex: 1,
flexDirection: "row",
alignItems: "center",
}}
>
<Chip
mode="outlined"
icon={() => (
<MaterialCommunityIcons
name={
libraryStatus
? "heart"
: "heart-outline"
}
size={21}
color={theme.colorAccentDark}
/>
)}
onPress={() => insertNovelInLib()}
style={[
{
backgroundColor: theme.colorDarkPrimaryDark,
marginRight: 2,
// borderColor: "rgba(255,255,255,0.121)",
// borderWidth: 1,
justifyContent: "center",
height: 30,
alignItems: "center",
marginLeft: 15,
paddingHorizontal: 3,
shadowOpacity: 0,
shadowOffset: 0,
},
// libraryStatus && {
// backgroundColor: "rgba(41,121,255,0.38)",
// },
]}
textStyle={{
fontWeight: "bold",
color: theme.textColorPrimaryDark,
}}
>
{libraryStatus ? "In Library" : "Add to library"}
</Chip>
<IconButton
onPress={() =>
WebBrowser.openBrowserAsync(
novel.sourceUrl && novel.sourceUrl
)
}
icon="earth"
color={theme.colorAccentDark}
size={21}
/>
<IconButton
onPress={() =>
Share.share({
message: novel.sourceUrl,
})
}
icon="share-variant"
color={theme.colorAccentDark}
size={21}
/>
</View>
{novel.novelSummary !== "" && (
<View
style={{
paddingHorizontal: 15,
marginBottom: 10,
// marginTop: 5,
}}
>
<Text
style={{
color: theme.textColorPrimaryDark,
marginTop: 5,
paddingVertical: 5,
fontSize: 15,
fontWeight: "bold",
}}
>
About
</Text>
<Text
style={{
color: theme.textColorSecondaryDark,
lineHeight: 20,
}}
numberOfLines={more ? 100 : 2}
onPress={() => setMore(!more)}
ellipsizeMode="clip"
>
{novel.novelSummary}
</Text>
<Text
style={{
color: theme.colorAccentDark,
fontWeight: "bold",
position: "absolute",
bottom: 0,
right: 15,
backgroundColor: theme.colorDarkPrimaryDark,
paddingLeft: 5,
}}
onPress={() => setMore(!more)}
>
{more ? "Less " : "More "}
</Text>
</View>
)}
<FlatList
style={
novel.novelSummary === ""
? { marginTop: 20 }
: { marginTop: 10 }
}
contentContainerStyle={{
paddingHorizontal: 15,
paddingBottom: 15,
}}
horizontal
data={novel["Genre(s)"] && novel["Genre(s)"].split(",")}
keyExtractor={(item) => item}
renderItem={renderGenreChip}
showsHorizontalScrollIndicator={false}
/>
<Button
color="white"
style={{
backgroundColor: theme.colorAccentDark,
marginHorizontal: 15,
marginTop: 8,
marginBottom: 15,
}}
uppercase={false}
labelStyle={{ letterSpacing: 0 }}
onPress={() =>
navigation.navigate("ChapterItem", {
chapterUrl: novel.lastRead,
novelUrl: novel.novelUrl,
extensionId: novel.extensionId,
chapterName: novel.lastReadName,
})
}
>
{novel.unread && novel.unread === 1
? `Start reading ${novel.lastReadName}`
: `Continue reading ${novel.lastReadName}`}
</Button>
<TouchableRipple
style={{
flex: 1,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingRight: 12.5,
}}
onPress={() =>
bottomSheetRef.current.show({ velocity: -1.5 })
}
rippleColor={theme.rippleColorDark}
>
<>
<Text
style={{
color: theme.textColorPrimaryDark,
paddingHorizontal: 15,
paddingVertical: 5,
fontSize: 16,
fontWeight: "bold",
}}
>
{noOfChapters + " Chapters "}
</Text>
<IconButton
icon="filter-variant"
color={theme.textColorPrimaryDark}
size={24}
/>
</>
</TouchableRipple>
</>
)}
</>
);
};
const mapStateToProps = (state) => ({
theme: state.themeReducer.theme,
});
export default connect(mapStateToProps)(NovelInfoHeader);
const styles = StyleSheet.create({
nameContainer: {
flex: 1,
marginHorizontal: 15,
paddingTop: 10,
},
background: {
height: 285,
// backgroundColor: theme.colorDarkPrimaryDark,
},
linearGradient: {
// flex: 1,
height: 286,
},
detailsContainer: {
flex: 1,
flexDirection: "row",
margin: 15,
paddingTop: 90,
},
logo: {
height: 160,
width: 110,
margin: 3.2,
borderRadius: 6,
},
genre: {
borderRadius: 50,
borderWidth: 1,
paddingHorizontal: 10,
marginHorizontal: 2,
fontSize: 13,
paddingVertical: 2,
justifyContent: "center",
flex: 1,
},
name: {
fontWeight: "bold",
fontSize: 18,
},
});
-35
View File
@@ -1,35 +0,0 @@
import React, { useState } from "react";
import { Checkbox } from "react-native-paper";
import { useSelector } from "react-redux";
export const DisplayCheckbox = ({ displayMode, onPress, value }) => {
const theme = useSelector((state) => state.themeReducer.theme);
return (
<Checkbox.Item
label={value.charAt(0).toUpperCase() + value.slice(1)}
labelStyle={{ color: theme.textColorPrimaryDark }}
status={displayMode === value ? "checked" : "unchecked"}
mode="ios"
uncheckedColor={theme.textColorSecondaryDark}
color={theme.colorAccentDark}
onPress={onPress}
/>
);
};
export const ThemeCheckbox = ({ onPress, label, checked }) => {
const theme = useSelector((state) => state.themeReducer.theme);
return (
<Checkbox.Item
label={label}
labelStyle={{ color: theme.textColorPrimaryDark }}
status={checked ? "checked" : "unchecked"}
mode="ios"
uncheckedColor={theme.textColorSecondaryDark}
color={theme.colorAccentDark}
onPress={onPress}
/>
);
};
-168
View File
@@ -1,168 +0,0 @@
import React from "react";
import {
createStackNavigator,
TransitionPresets,
} from "@react-navigation/stack";
import { createMaterialBottomTabNavigator } from "@react-navigation/material-bottom-tabs";
import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons";
import Library from "../screens/Library";
import History from "../screens/history/History";
import Browse from "../screens/Browse";
import More from "../screens/more/More";
import About from "../screens/more/About";
import Settings from "../screens/more/Settings";
import NovelItem from "../screens/NovelItem";
import ChapterItem from "../screens/ChapterItem";
// Extensions
import BoxNovel from "../screens/extensions/boxnovel/BoxNovel";
import ReadLightNovel from "../screens/extensions/readlightnovel/ReadLightNovel";
const Stack = createStackNavigator();
const Tab = createMaterialBottomTabNavigator();
const stackNavigatorConfig = {
headerShown: false,
...TransitionPresets.RevealFromBottomAndroid,
};
const MoreStack = () => {
return (
<Stack.Navigator screenOptions={stackNavigatorConfig}>
<Stack.Screen name="More" component={More} />
<Stack.Screen name="About" component={About} />
<Stack.Screen name="Settings" component={Settings} />
</Stack.Navigator>
);
};
const BoxNovelStack = () => {
return (
<Stack.Navigator screenOptions={stackNavigatorConfig}>
<Stack.Screen name="BoxNovel" component={BoxNovel} />
</Stack.Navigator>
);
};
const ReadLightNovelStack = () => {
return (
<Stack.Navigator screenOptions={stackNavigatorConfig}>
<Stack.Screen name="ReadLightNovel" component={ReadLightNovel} />
</Stack.Navigator>
);
};
import { useSelector } from "react-redux";
const BottomNavigator = () => {
const theme = useSelector((state) => state.themeReducer.theme);
return (
<Tab.Navigator
shifting={false}
barStyle={{ backgroundColor: theme.colorDarkPrimary }}
activeColor={theme.colorAccentDark}
>
<Tab.Screen
name="My 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={MoreStack}
options={{
tabBarIcon: ({ color }) => (
<MaterialCommunityIcons
name="dots-horizontal"
color={color}
size={24}
/>
),
}}
/>
</Tab.Navigator>
);
};
const Router = () => {
return (
<Stack.Navigator screenOptions={stackNavigatorConfig}>
<Stack.Screen name="Router" component={BottomNavigator} />
<Stack.Screen
name="ChapterItem"
component={ChapterItem}
options={{ ...TransitionPresets.SlideFromRightIOS }}
/>
<Stack.Screen
name="NovelItem"
component={NovelItem}
options={{
headerTitle: "",
headerShown: true,
headerTransparent: true,
headerTintColor: "white",
}}
/>
<Stack.Screen name="BoxNovelStack" component={BoxNovelStack} />
<Stack.Screen
name="ReadLightNovelStack"
component={ReadLightNovelStack}
/>
</Stack.Navigator>
);
};
export default Router;
-11
View File
@@ -1,11 +0,0 @@
import { GET_LIBRARY_NOVELS } from "./types";
import { getLibraryFromDb } from "../../services/db";
export const getLibraryNovels = () => async (dispatch) => {
getLibraryFromDb().then((res) => console.log(res));
dispatch({
type: GET_LIBRARY_NOVELS,
payload: res.data,
});
};
-8
View File
@@ -1,8 +0,0 @@
import { SWITCH_THEME } from "./types";
export const switchTheme = (theme) => (dispatch) => {
dispatch({
type: SWITCH_THEME,
payload: theme,
});
};

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