Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f8071e48e | |||
| a1c5350e8e | |||
| 8f237909d5 | |||
| 11ff69d4f5 | |||
| d15cf98742 | |||
| 29bd6612e9 | |||
| ab1e36d280 | |||
| f1fdafd37f | |||
| 4c7684a3a7 | |||
| 2f45db659b | |||
| 9e38ad114d | |||
| 45c4ea8ca0 | |||
| d2b95ae316 | |||
| 02121152b3 | |||
| caed965b6b | |||
| 4088d5966b | |||
| a062beee87 | |||
| 3095882516 | |||
| 4ad0639796 | |||
| b53ec3352f | |||
| c40edd5b2c | |||
| bdd42caaaf | |||
| ab4eee31ce | |||
| 3f9886a2e5 | |||
| 198b7443ae | |||
| f9c5ca303d | |||
| 9922d43940 | |||
| ae99b077d4 | |||
| 0cb9da9027 | |||
| 467a97dcf7 | |||
| 8d15e418e7 | |||
| 3849b797c9 |
@@ -45,7 +45,7 @@ body:
|
||||
label: LNReader version
|
||||
description: You can find your LNReader version in **More → About**.
|
||||
placeholder: |
|
||||
Example: "2.0.2"
|
||||
Example: "2.0.3"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -88,7 +88,7 @@ body:
|
||||
required: true
|
||||
- label: If this is an issue with a plugin, I should be opening an issue in the [plugins repository](https://github.com/lnreader/lnreader-plugins/issues/new/choose).
|
||||
required: true
|
||||
- label: I have updated the app to version **[2.0.2](https://github.com/lnreader/lnreader/releases/latest)**.
|
||||
- label: I have updated the app to version **[2.0.3](https://github.com/lnreader/lnreader/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
|
||||
@@ -32,9 +32,9 @@ body:
|
||||
required: true
|
||||
- label: I have written a short but informative title.
|
||||
required: true
|
||||
- label: If this is an issue with a source, I should be opening an issue in the [sources repository](https://github.com/LNReader/lnreader-sources/issues/new/choose).
|
||||
- label: If this is an issue with a source, I should be opening an issue in the [sources repository](https://github.com/lnreader/lnreader-sources/issues/new/choose).
|
||||
required: true
|
||||
- label: I have updated the app to version **[2.0.2](https://github.com/LNReader/lnreader/releases/latest)**.
|
||||
- label: I have updated the app to version **[2.0.3](https://github.com/lnreader/lnreader/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
COMMIT_COUNT: ${{ env.COMMIT_COUNT }}
|
||||
COMMIT_ID: ${{ env.COMMIT_ID }}
|
||||
run: |
|
||||
sed -i 's/LNReader/LNReader-r${{ env.COMMIT_COUNT }}(${{ env.COMMIT_ID }})/g' android/app/src/main/res/values/strings.xml
|
||||
sed -i 's/lnreader/lnreader-r${{ env.COMMIT_COUNT }}(${{ env.COMMIT_ID }})/g' android/app/src/main/res/values/strings.xml
|
||||
cd android && ./gradlew assembleRelease -PcustomAppId=com.rajarsheechatterjee.LNReader.commit_${{ env.COMMIT_ID }} --build-cache
|
||||
mv app/build/outputs/apk/release/app-release.apk app/build/outputs/apk/release/LNReader-r${{ env.COMMIT_COUNT }}-${{ env.COMMIT_ID }}.apk
|
||||
|
||||
|
||||
+3
-1
@@ -89,4 +89,6 @@ flake.lock
|
||||
# pnpm
|
||||
.pnpm-store
|
||||
|
||||
.cursor/
|
||||
.cursor/
|
||||
.agents/
|
||||
.claude/
|
||||
@@ -14,15 +14,15 @@
|
||||
<a href="https://discord.gg/QdcWN4MD63">
|
||||
<img alt="Discord Chat" src="https://img.shields.io/discord/835746409357246465.svg?logo=discord&logoColor=white&logoWidth=20&labelColor=5865F2&color=4752C4&label=discord&style=flat">
|
||||
</a>
|
||||
<a href="https://github.com/LNReader/lnreader/releases">
|
||||
<img alt="GitHub Downloads" src="https://img.shields.io/github/downloads/LNReader/lnreader/total?label=downloads&labelColor=27303D&color=0D1117&logo=github&logoColor=FFFFFF&style=flat">
|
||||
<a href="https://github.com/lnreader/lnreader/releases">
|
||||
<img alt="GitHub Downloads" src="https://img.shields.io/github/downloads/lnreader/lnreader/total?label=downloads&labelColor=27303D&color=0D1117&logo=github&logoColor=FFFFFF&style=flat">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/LNReader/lnreader/build.yml?labelColor=27303D&style=flat">
|
||||
<a href="https://github.com/LNReader/lnreader/blob/main/LICENSE">
|
||||
<img alt="GitHub" src="https://img.shields.io/github/license/LNReader/lnreader?labelColor=27303D&color=1a73e8&style=flat">
|
||||
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/lnreader/lnreader/build.yml?labelColor=27303D&style=flat">
|
||||
<a href="https://github.com/lnreader/lnreader/blob/main/LICENSE">
|
||||
<img alt="GitHub" src="https://img.shields.io/github/license/lnreader/lnreader?labelColor=27303D&color=1a73e8&style=flat">
|
||||
</a>
|
||||
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/lnreader">
|
||||
<img src="https://badges.crowdin.net/lnreader/localized.svg">
|
||||
@@ -32,16 +32,16 @@
|
||||
<h2 align="center">Download</h2>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/LNReader/lnreader/releases/latest">
|
||||
<img alt="GitHub release (latest by date)" src="https://img.shields.io/github/v/release/LNReader/lnreader?label=Stable&labelColor=0d7377&color=084c4e&style=flat">
|
||||
<a href="https://github.com/lnreader/lnreader/releases/latest">
|
||||
<img alt="GitHub release (latest by date)" src="https://img.shields.io/github/v/release/lnreader/lnreader?label=Stable&labelColor=0d7377&color=084c4e&style=flat">
|
||||
</a>
|
||||
<a href="https://github.com/LNReader/lnreader/releases/latest">
|
||||
<img alt="GitHub release (latest SemVer)" src="https://img.shields.io/github/v/release/LNReader/lnreader?include_prereleases&sort=semver&label=Beta&labelColor=3d3d5c&color=2a2a47&style=flat">
|
||||
<a href="https://github.com/lnreader/lnreader/releases/latest">
|
||||
<img alt="GitHub release (latest SemVer)" src="https://img.shields.io/github/v/release/lnreader/lnreader?include_prereleases&sort=semver&label=Beta&labelColor=3d3d5c&color=2a2a47&style=flat">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Get the app from our <a href="https://github.com/LNReader/lnreader/releases">releases page</a>.
|
||||
Get the app from our <a href="https://github.com/lnreader/lnreader/releases">releases page</a>.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
LNReader does not have any affiliation with the content providers available.
|
||||
|
||||
Plugin requests should be created at [lnreader-plugins](https://github.com/LNReader/lnreader-plugins).
|
||||
Plugin requests should be created at [lnreader-plugins](https://github.com/lnreader/lnreader-plugins).
|
||||
|
||||
## Translation
|
||||
|
||||
@@ -70,4 +70,4 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md)
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://github.com/LNReader/lnreader/blob/main/LICENSE)
|
||||
[MIT](https://github.com/lnreader/lnreader/blob/main/LICENSE)
|
||||
|
||||
@@ -75,7 +75,7 @@ def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||
ext {
|
||||
versionMajor = 2
|
||||
versionMinor = 0
|
||||
versionPatch = 2
|
||||
versionPatch = 3
|
||||
}
|
||||
|
||||
android {
|
||||
|
||||
@@ -74,13 +74,21 @@ class NativeFile(context: ReactApplicationContext) :
|
||||
}
|
||||
|
||||
override fun writeFile(path: String, content: String) {
|
||||
val fw = FileWriter(path)
|
||||
fw.write(content)
|
||||
fw.close()
|
||||
try {
|
||||
val fw = FileWriter(path)
|
||||
fw.write(content)
|
||||
fw.close()
|
||||
} catch (e: IOException) {
|
||||
throw Exception("Failed to write file '$path': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun readFile(path: String): String {
|
||||
return File(path).bufferedReader().readText()
|
||||
val file = File(path)
|
||||
if (!file.exists()) {
|
||||
throw Exception("File not found: '$path'")
|
||||
}
|
||||
return file.bufferedReader().readText()
|
||||
}
|
||||
|
||||
override fun copyFile(filepath: String, destPath: String) {
|
||||
@@ -97,17 +105,27 @@ class NativeFile(context: ReactApplicationContext) :
|
||||
destPath: String,
|
||||
onDone: (() -> Unit)? = null,
|
||||
) {
|
||||
val inputStream = getInputStream(filepath)
|
||||
val outputStream = getOutputStream(destPath)
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var length: Int
|
||||
while (inputStream.read(buffer).also { length = it } > 0) {
|
||||
outputStream.write(buffer, 0, length)
|
||||
}
|
||||
inputStream.close()
|
||||
outputStream.close()
|
||||
if (onDone != null) {
|
||||
onDone()
|
||||
try {
|
||||
val inputStream = getInputStream(filepath)
|
||||
try {
|
||||
val outputStream = getOutputStream(destPath)
|
||||
try {
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var length: Int
|
||||
while (inputStream.read(buffer).also { length = it } > 0) {
|
||||
outputStream.write(buffer, 0, length)
|
||||
}
|
||||
} finally {
|
||||
outputStream.close()
|
||||
}
|
||||
} finally {
|
||||
inputStream.close()
|
||||
}
|
||||
if (onDone != null) {
|
||||
onDone()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw Exception("Failed to copy file from '$filepath' to '$destPath': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,18 +209,21 @@ class NativeFile(context: ReactApplicationContext) :
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
promise.reject(Exception("Failed to download load: ${response.code}"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
val inputStream = decompressStream(response.body!!.byteStream())
|
||||
FileOutputStream(destPath).use { fos ->
|
||||
inputStream.copyTo(fos, BUFFER_SIZE)
|
||||
response.use {
|
||||
if (!it.isSuccessful || it.body == null) {
|
||||
promise.reject(Exception("Failed to download: ${it.code}"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
decompressStream(it.body!!.byteStream()).use { inputStream ->
|
||||
FileOutputStream(destPath).use { fos ->
|
||||
inputStream.copyTo(fos, BUFFER_SIZE)
|
||||
}
|
||||
}
|
||||
promise.resolve(null)
|
||||
} catch (e: Exception) {
|
||||
promise.reject(e)
|
||||
}
|
||||
promise.resolve(null)
|
||||
} catch (e: Exception) {
|
||||
promise.reject(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,7 +11,15 @@
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="android:navigationBarColor">@color/colorPrimaryDark</item>
|
||||
<item name="android:statusBarColor">@color/colorPrimaryDark</item>
|
||||
<item name="colorControlActivated">#2596be</item>
|
||||
<item name="colorControlActivated">#2596be</item>
|
||||
</style>
|
||||
|
||||
<style name="SplashScreen_SplashAnimation">
|
||||
<item name="android:windowExitAnimation">@android:anim/fade_out</item>
|
||||
</style>
|
||||
|
||||
<style name="SplashScreen_SplashTheme" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="android:windowAnimationStyle">@style/SplashScreen_SplashAnimation</item>
|
||||
</style>
|
||||
|
||||
<style name="SplashScreenTheme" parent="SplashScreen_SplashTheme">
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
@@ -1,5 +1,11 @@
|
||||
import 'react-native-gesture-handler';
|
||||
import { registerRootComponent } from 'expo';
|
||||
import { I18nManager } from 'react-native';
|
||||
import { i18n } from './strings/translations';
|
||||
|
||||
const isRTL = i18n.locale.startsWith('ar') || i18n.locale.startsWith('he');
|
||||
I18nManager.allowRTL(isRTL);
|
||||
I18nManager.forceRTL(isRTL);
|
||||
|
||||
import App from './App';
|
||||
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lnreader",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:android": "pnpm run generate:env:debug && react-native run-android --appIdSuffix \"debug\" --active-arch-only",
|
||||
@@ -42,6 +42,7 @@
|
||||
"@cd-z/react-native-epub-creator": "^3.0.0",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@legendapp/list": "^2.0.16",
|
||||
"@noble/ciphers": "^2.1.1",
|
||||
"@react-native-community/slider": "^5.1.1",
|
||||
"@react-native-cookies/cookies": "^6.2.1",
|
||||
"@react-native-documents/picker": "^10.1.7",
|
||||
@@ -76,6 +77,7 @@
|
||||
"i18n-js": "^4.5.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lottie-ios": "^3.5.0",
|
||||
"lottie-react-native": "^5.1.3",
|
||||
"protobufjs": "^7.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-native": "^0.81.5",
|
||||
|
||||
Generated
+5153
-1809
File diff suppressed because it is too large
Load Diff
@@ -73,10 +73,8 @@ export const Actionbar: React.FC<ActionbarProps> = ({
|
||||
const styles = StyleSheet.create({
|
||||
actionbarContainer: {
|
||||
alignItems: 'center',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
bottom: 0,
|
||||
elevation: 1,
|
||||
elevation: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
position: 'absolute',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useBackHandler } from '@hooks/index';
|
||||
import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
|
||||
import BottomSheetBackdrop from './BottomSheetBackdrop';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
|
||||
interface BottomSheetProps
|
||||
extends Omit<BottomSheetModalProps, 'ref' | 'onChange' | 'snapPoints'> {
|
||||
@@ -18,6 +19,33 @@ interface BottomSheetProps
|
||||
snapPoints?: number[];
|
||||
}
|
||||
|
||||
// const MD3DragHandle = () => {
|
||||
// const theme = useTheme();
|
||||
// return (
|
||||
// <View style={[handleStyles.container, { backgroundColor: theme.surface }]}>
|
||||
// <View
|
||||
// style={[
|
||||
// handleStyles.handle,
|
||||
// { backgroundColor: theme.onSurfaceVariant },
|
||||
// ]}
|
||||
// />
|
||||
// </View>
|
||||
// );
|
||||
// };
|
||||
|
||||
// const handleStyles = StyleSheet.create({
|
||||
// container: {
|
||||
// alignItems: 'center',
|
||||
// paddingVertical: 12,
|
||||
// },
|
||||
// handle: {
|
||||
// width: 32,
|
||||
// height: 4,
|
||||
// borderRadius: 2,
|
||||
// opacity: 0.4,
|
||||
// },
|
||||
// });
|
||||
|
||||
const BottomSheet: React.FC<BottomSheetProps> = ({
|
||||
bottomSheetRef,
|
||||
children,
|
||||
@@ -29,6 +57,7 @@ const BottomSheet: React.FC<BottomSheetProps> = ({
|
||||
const indexRef = useRef<number>(null);
|
||||
const { bottom, top } = useSafeAreaInsets();
|
||||
const { height } = useWindowDimensions();
|
||||
const theme = useTheme();
|
||||
const renderBackdrop = useCallback(
|
||||
(backdropProps: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop {...backdropProps} />
|
||||
@@ -61,6 +90,11 @@ const BottomSheet: React.FC<BottomSheetProps> = ({
|
||||
ref={bottomSheetRef}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleComponent={null}
|
||||
backgroundStyle={{
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
backgroundColor: theme.surface,
|
||||
}}
|
||||
containerStyle={[{ paddingBottom: bottom }, containerStyle]}
|
||||
onChange={index => {
|
||||
onChange?.(index);
|
||||
|
||||
@@ -100,7 +100,7 @@ function CustomBottomTabBar({
|
||||
transitionDuration: 250,
|
||||
transitionTimingFunction: 'ease-in-out',
|
||||
marginBottom: showLabel ? 4 : 20,
|
||||
width: isFocused ? 52 : 32,
|
||||
width: isFocused ? 64 : 32,
|
||||
backgroundColor: isFocused
|
||||
? theme.primaryContainer
|
||||
: 'transparent',
|
||||
@@ -116,8 +116,8 @@ function CustomBottomTabBar({
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color: theme.onSurfaceVariant,
|
||||
fontWeight: '700',
|
||||
color: isFocused ? theme.onSurface : theme.onSurfaceVariant,
|
||||
fontWeight: '500',
|
||||
},
|
||||
]}
|
||||
numberOfLines={1}
|
||||
@@ -138,7 +138,8 @@ export type { CustomBottomTabBarProps };
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 4,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 0,
|
||||
minHeight: 80,
|
||||
},
|
||||
@@ -154,8 +155,8 @@ const styles = StyleSheet.create({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: 24,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
},
|
||||
label: {
|
||||
height: 16,
|
||||
|
||||
@@ -42,8 +42,8 @@ export const Checkbox: React.FC<CheckboxProps> = ({
|
||||
status === 'indeterminate'
|
||||
? 'indeterminate'
|
||||
: status
|
||||
? 'checked'
|
||||
: 'unchecked'
|
||||
? 'checked'
|
||||
: 'unchecked'
|
||||
}
|
||||
onPress={onPress}
|
||||
color={theme.primary}
|
||||
@@ -85,11 +85,11 @@ export const SortItem = ({ label, status, onPress, theme }: SortItemProps) => (
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
defaultLabel: {
|
||||
marginLeft: 12,
|
||||
marginStart: 12,
|
||||
},
|
||||
icon: {
|
||||
alignSelf: 'center',
|
||||
left: 24,
|
||||
start: 24,
|
||||
position: 'absolute',
|
||||
},
|
||||
pressable: {
|
||||
@@ -98,5 +98,5 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
sortItem: { paddingVertical: 16, paddingLeft: 64 },
|
||||
sortItem: { paddingVertical: 16, paddingStart: 64 },
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { ThemeColors } from '../../theme/types';
|
||||
import { overlay } from 'react-native-paper';
|
||||
|
||||
interface ChipProps {
|
||||
label: string;
|
||||
@@ -14,9 +13,7 @@ const Chip: React.FC<ChipProps> = ({ label, theme }) => (
|
||||
style={[
|
||||
styles.chipContainer,
|
||||
{
|
||||
backgroundColor: theme.isDark
|
||||
? overlay(1, theme.surface)
|
||||
: theme.secondaryContainer,
|
||||
backgroundColor: theme.secondaryContainer,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -28,7 +25,7 @@ const Chip: React.FC<ChipProps> = ({ label, theme }) => (
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color: theme.isDark ? theme.onSurface : theme.onSecondaryContainer,
|
||||
color: theme.onSecondaryContainer,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -44,11 +41,11 @@ const styles = StyleSheet.create({
|
||||
chipContainer: {
|
||||
borderRadius: 8,
|
||||
height: 32,
|
||||
marginRight: 8,
|
||||
marginEnd: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
pressable: {
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -37,7 +37,7 @@ const styles = StyleSheet.create({
|
||||
colorPreview: {
|
||||
borderRadius: 50,
|
||||
height: 24,
|
||||
marginRight: 16,
|
||||
marginEnd: 16,
|
||||
width: 24,
|
||||
},
|
||||
container: {
|
||||
|
||||
@@ -65,7 +65,7 @@ const styles = StyleSheet.create({
|
||||
shadowColor: 'transparent',
|
||||
},
|
||||
content: {
|
||||
fontSize: 16,
|
||||
fontSize: 14,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,7 +41,11 @@ const IconButton: React.FC<Props> = ({
|
||||
<MaterialCommunityIcons
|
||||
name={name}
|
||||
size={size}
|
||||
color={disabled ? theme.outline : color || theme.onSurface}
|
||||
color={
|
||||
disabled
|
||||
? Color(theme.onSurface).alpha(0.38).string()
|
||||
: color || theme.onSurface
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -33,7 +33,7 @@ const SubHeader = ({
|
||||
children: ReactNode;
|
||||
theme: ThemeColors;
|
||||
}) => (
|
||||
<PaperList.Subheader style={{ color: theme.primary }}>
|
||||
<PaperList.Subheader style={{ color: theme.onSurfaceVariant }}>
|
||||
{children}
|
||||
</PaperList.Subheader>
|
||||
);
|
||||
@@ -57,7 +57,7 @@ const Item: React.FC<ListItemProps> = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [icon, theme.primary]);
|
||||
}, [icon, theme.onSurfaceVariant]);
|
||||
const rightIcon = useCallback(() => {
|
||||
if (right) {
|
||||
return (
|
||||
@@ -68,7 +68,7 @@ const Item: React.FC<ListItemProps> = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [right, theme.primary]);
|
||||
}, [right, theme.onSurfaceVariant]);
|
||||
return (
|
||||
<PaperList.Item
|
||||
title={title}
|
||||
@@ -93,7 +93,9 @@ const Item: React.FC<ListItemProps> = ({
|
||||
};
|
||||
|
||||
const Divider = ({ theme }: { theme: ThemeColors }) => (
|
||||
<PaperDivider style={[styles.divider, { backgroundColor: theme.outline }]} />
|
||||
<PaperDivider
|
||||
style={[styles.divider, { backgroundColor: theme.outlineVariant }]}
|
||||
/>
|
||||
);
|
||||
|
||||
const InfoItem = ({
|
||||
@@ -109,7 +111,7 @@ const InfoItem = ({
|
||||
<View style={[styles.infoCtn, style]}>
|
||||
<MaterialIcon
|
||||
size={20}
|
||||
color={theme.onSurfaceVariant}
|
||||
color={theme.primary}
|
||||
name={'information-outline'}
|
||||
/>
|
||||
<Text style={[styles.infoMsg, { color: theme.onSurfaceVariant }]}>
|
||||
@@ -171,18 +173,17 @@ const styles = StyleSheet.create({
|
||||
height: 24,
|
||||
width: 24,
|
||||
borderRadius: 50,
|
||||
marginRight: 16,
|
||||
marginEnd: 16,
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
opacity: 0.5,
|
||||
},
|
||||
iconCtn: {
|
||||
paddingLeft: 16,
|
||||
paddingStart: 16,
|
||||
},
|
||||
infoCtn: {
|
||||
paddingHorizontal: 16,
|
||||
|
||||
@@ -88,7 +88,7 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 15,
|
||||
marginLeft: 16,
|
||||
paddingRight: 8,
|
||||
marginStart: 16,
|
||||
paddingEnd: 8,
|
||||
},
|
||||
});
|
||||
|
||||
+139
-81
@@ -1,30 +1,46 @@
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Pressable, StyleSheet, View, Dimensions } from 'react-native';
|
||||
import React, {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
TextStyle,
|
||||
LayoutRectangle,
|
||||
} from 'react-native';
|
||||
import { ScrollView } from 'react-native-gesture-handler';
|
||||
import { Portal } from 'react-native-paper';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeInUp,
|
||||
FadeOut,
|
||||
FadeOutUp,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
ExitAnimationsValues,
|
||||
EntryAnimationsValues,
|
||||
withDelay,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
interface MenuProps {
|
||||
visible: boolean;
|
||||
onDismiss: () => void;
|
||||
anchor: React.ReactNode;
|
||||
contentStyle?: any;
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
children: React.ReactNode;
|
||||
fullWidth?: boolean; // Full width of the anchor
|
||||
}
|
||||
|
||||
interface MenuItemProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
style?: any;
|
||||
titleStyle?: any;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
titleStyle?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
const Menu: React.FC<MenuProps> & { Item: React.FC<MenuItemProps> } = ({
|
||||
@@ -33,85 +49,119 @@ const Menu: React.FC<MenuProps> & { Item: React.FC<MenuItemProps> } = ({
|
||||
anchor,
|
||||
contentStyle,
|
||||
children,
|
||||
fullWidth,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [anchorLayout, setAnchorLayout] = useState({
|
||||
const duration = 250;
|
||||
|
||||
const [menuLayout, setMenuLayout] = useState<LayoutRectangle | null>(null);
|
||||
const [anchorLayout, setAnchorLayout] = useState<LayoutRectangle>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
const [isMeasured, setIsMeasured] = useState(false);
|
||||
|
||||
const menuAnimatedStyle = useAnimatedStyle(() => ({
|
||||
shadowColor: theme.isDark ? '#000' : theme.shadow,
|
||||
position: 'absolute' as const,
|
||||
left: Math.max(16, Math.min(anchorLayout.x, screenWidth - 220)),
|
||||
top: anchorLayout.y + anchorLayout.height + 8,
|
||||
width: Math.min(200, screenWidth - 32),
|
||||
zIndex: 1001,
|
||||
}));
|
||||
const backdropEntering = FadeIn.duration(duration);
|
||||
const menuEntering = (values: EntryAnimationsValues) => {
|
||||
'worklet';
|
||||
const animations = {
|
||||
height: withTiming(values.targetHeight, { duration }),
|
||||
opacity: withTiming(1, { duration: duration - 100 }),
|
||||
};
|
||||
const initialValues = {
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
};
|
||||
return {
|
||||
initialValues,
|
||||
animations,
|
||||
};
|
||||
};
|
||||
|
||||
// Create entering animations
|
||||
const backdropEntering = FadeIn.duration(150);
|
||||
const menuEntering = FadeInUp.duration(150)
|
||||
.springify()
|
||||
.damping(30)
|
||||
.stiffness(500)
|
||||
.mass(0.3)
|
||||
.withInitialValues({
|
||||
transform: [{ translateY: -10 }],
|
||||
const backdropExiting = FadeOut.duration(duration);
|
||||
const menuExiting = (values: ExitAnimationsValues) => {
|
||||
'worklet';
|
||||
const initialValues = {
|
||||
height: values.currentHeight,
|
||||
opacity: 1,
|
||||
};
|
||||
const animations = {
|
||||
height: withTiming(0, { duration }),
|
||||
opacity: withDelay(100, withTiming(0, { duration: duration - 100 })),
|
||||
};
|
||||
return {
|
||||
initialValues,
|
||||
animations,
|
||||
};
|
||||
};
|
||||
const measureAnchor = useCallback(() => {
|
||||
anchorRef.current?.measureInWindow((x, y, width, height) => {
|
||||
setAnchorLayout({ x, y, width, height });
|
||||
});
|
||||
|
||||
// Create exiting animations
|
||||
const backdropExiting = FadeOut.duration(150);
|
||||
const menuExiting = FadeOutUp.duration(150)
|
||||
.springify()
|
||||
.damping(30)
|
||||
.stiffness(500)
|
||||
.mass(0.3);
|
||||
|
||||
const measureAnchor = React.useCallback(() => {
|
||||
if (anchorRef.current) {
|
||||
anchorRef.current.measure((x, y, width, height, pageX, pageY) => {
|
||||
setAnchorLayout({ x: pageX, y: pageY, width, height });
|
||||
setIsMeasured(true);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Measure anchor on mount
|
||||
useEffect(() => {
|
||||
setTimeout(measureAnchor, 0);
|
||||
}, [measureAnchor]);
|
||||
useLayoutEffect(() => {
|
||||
if (visible) {
|
||||
measureAnchor();
|
||||
} else {
|
||||
setMenuLayout(null); // Reset layout when closed
|
||||
}
|
||||
}, [measureAnchor, visible]);
|
||||
|
||||
if (!visible) {
|
||||
return (
|
||||
<View ref={anchorRef} collapsable={false} onLayout={measureAnchor}>
|
||||
{anchor}
|
||||
</View>
|
||||
const menuPosition = useMemo(() => {
|
||||
if (!menuLayout) return { opacity: 0 };
|
||||
const leftPos = Math.max(
|
||||
16,
|
||||
Math.min(anchorLayout.x, screenWidth - menuLayout.width - 16),
|
||||
);
|
||||
}
|
||||
|
||||
let topPos = anchorLayout.y + anchorLayout.height + 24;
|
||||
|
||||
const showAbove = topPos + menuLayout.height > screenHeight;
|
||||
if (showAbove) {
|
||||
topPos -= anchorLayout.height + menuLayout.height;
|
||||
}
|
||||
|
||||
const maxWidth = fullWidth
|
||||
? anchorLayout.width
|
||||
: Math.min(250, screenWidth - 32);
|
||||
|
||||
return {
|
||||
left: leftPos,
|
||||
top: topPos,
|
||||
shadowColor: theme.isDark ? '#000' : theme.shadow,
|
||||
[fullWidth ? 'width' : 'maxWidth']: maxWidth,
|
||||
};
|
||||
}, [
|
||||
anchorLayout.height,
|
||||
anchorLayout.width,
|
||||
anchorLayout.x,
|
||||
anchorLayout.y,
|
||||
fullWidth,
|
||||
menuLayout,
|
||||
screenHeight,
|
||||
screenWidth,
|
||||
theme.isDark,
|
||||
theme.shadow,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View ref={anchorRef} collapsable={false} onLayout={measureAnchor}>
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
{anchor}
|
||||
</View>
|
||||
|
||||
{visible && isMeasured && (
|
||||
{visible && (
|
||||
<Portal>
|
||||
{/* Backdrop */}
|
||||
<Pressable style={StyleSheet.absoluteFillObject} onPress={onDismiss}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.backdrop,
|
||||
{
|
||||
backgroundColor: theme.isDark
|
||||
? 'rgba(0, 0, 0, 0.2)'
|
||||
: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
theme.isDark ? styles.backdropDark : styles.backdropLight,
|
||||
]}
|
||||
entering={backdropEntering}
|
||||
exiting={backdropExiting}
|
||||
@@ -120,16 +170,22 @@ const Menu: React.FC<MenuProps> & { Item: React.FC<MenuItemProps> } = ({
|
||||
|
||||
{/* Menu */}
|
||||
<Animated.View
|
||||
key={menuLayout ? 'ready' : 'measuring'}
|
||||
style={[
|
||||
styles.menuContainer,
|
||||
menuAnimatedStyle,
|
||||
{ backgroundColor: theme.surface },
|
||||
{ backgroundColor: theme.surface2 || theme.surface },
|
||||
contentStyle,
|
||||
menuPosition,
|
||||
]}
|
||||
entering={menuEntering}
|
||||
exiting={menuExiting}
|
||||
onLayout={e => {
|
||||
setMenuLayout(e.nativeEvent.layout);
|
||||
}}
|
||||
entering={menuLayout ? menuEntering : undefined}
|
||||
exiting={menuLayout ? menuExiting : undefined}
|
||||
>
|
||||
{children}
|
||||
<ScrollView style={{ maxHeight: screenHeight * 0.6 }}>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Portal>
|
||||
)}
|
||||
@@ -152,13 +208,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
||||
android_ripple={{ color: theme.rippleColor, foreground: true }}
|
||||
>
|
||||
<Animated.Text
|
||||
style={[
|
||||
styles.menuItemText,
|
||||
{
|
||||
color: theme.onSurface,
|
||||
},
|
||||
titleStyle,
|
||||
]}
|
||||
style={[styles.menuItemText, { color: theme.onSurface }, titleStyle]}
|
||||
>
|
||||
{title}
|
||||
</Animated.Text>
|
||||
@@ -170,25 +220,33 @@ Menu.Item = MenuItem;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
menuContainer: {
|
||||
borderRadius: 8,
|
||||
elevation: 8,
|
||||
borderRadius: 4,
|
||||
elevation: 2,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
height: 1,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 6,
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 3,
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
zIndex: 1001,
|
||||
},
|
||||
backdrop: {
|
||||
position: 'absolute' as const,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
backdropDark: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
backdropLight: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
menuItem: {
|
||||
paddingHorizontal: 16,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
minHeight: 48,
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -62,7 +62,8 @@ interface INovelCover<TNovel> {
|
||||
addSkeletonLoading?: boolean;
|
||||
inActivity?: boolean;
|
||||
onLongPress: (item: TNovel) => void;
|
||||
selectedNovelIds: number[];
|
||||
hasSelection?: boolean;
|
||||
selectedNovelIds?: number[];
|
||||
globalSearch?: boolean;
|
||||
imageRequestInit?: ImageRequestInit;
|
||||
}
|
||||
@@ -84,10 +85,13 @@ function NovelCover<
|
||||
addSkeletonLoading,
|
||||
inActivity,
|
||||
onLongPress,
|
||||
hasSelection,
|
||||
globalSearch,
|
||||
selectedNovelIds,
|
||||
imageRequestInit,
|
||||
}: INovelCover<TNovel>) {
|
||||
const selectionActive =
|
||||
hasSelection ?? (selectedNovelIds != null && selectedNovelIds.length > 0);
|
||||
const {
|
||||
displayMode = DisplayModes.Comfortable,
|
||||
showDownloadBadges = true,
|
||||
@@ -162,7 +166,7 @@ function NovelCover<
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
style={styles.opac}
|
||||
onPress={
|
||||
selectedNovelIds && selectedNovelIds.length > 0
|
||||
selectionActive
|
||||
? selectNovel
|
||||
: onPress
|
||||
}
|
||||
@@ -243,7 +247,7 @@ function NovelCover<
|
||||
inLibraryBadge={libraryStatus && <InLibraryBadge theme={theme} />}
|
||||
theme={theme}
|
||||
onPress={
|
||||
selectedNovelIds && selectedNovelIds.length > 0 ? selectNovel : onPress
|
||||
selectionActive ? selectNovel : onPress
|
||||
}
|
||||
onLongPress={selectNovel}
|
||||
isSelected={isSelected}
|
||||
|
||||
@@ -23,6 +23,9 @@ interface NovelListProps extends FlatListProps<NovelInfo | NovelItem> {
|
||||
data: Array<listDataItem>;
|
||||
}
|
||||
|
||||
const novelListKeyExtractor = (item: NovelInfo | NovelItem, index: number) =>
|
||||
index + '_' + item.path;
|
||||
|
||||
const NovelList: React.FC<NovelListProps> = props => {
|
||||
const { displayMode = DisplayModes.Comfortable, novelsPerRow = 3 } =
|
||||
useLibrarySettings();
|
||||
@@ -74,7 +77,7 @@ const NovelList: React.FC<NovelListProps> = props => {
|
||||
]}
|
||||
numColumns={numColumns}
|
||||
key={numColumns}
|
||||
keyExtractor={(item, index) => index + '_' + item.path}
|
||||
keyExtractor={novelListKeyExtractor}
|
||||
{...props}
|
||||
data={extendedNovelList}
|
||||
/>
|
||||
|
||||
@@ -57,6 +57,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
radioButtonLabel: {
|
||||
fontSize: 16,
|
||||
marginLeft: 16,
|
||||
marginStart: 16,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,11 +48,11 @@ export const RadioButton: React.FC<Props> = ({
|
||||
const styles = StyleSheet.create({
|
||||
icon: {
|
||||
alignSelf: 'center',
|
||||
left: 24,
|
||||
start: 24,
|
||||
position: 'absolute',
|
||||
},
|
||||
label: {
|
||||
marginLeft: 12,
|
||||
marginStart: 12,
|
||||
},
|
||||
pressable: {
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -22,8 +22,8 @@ const SafeAreaView: React.FC<SafeAreaViewProps> = ({
|
||||
},
|
||||
padding: {
|
||||
paddingBottom: excludeBottom ? 0 : bottom,
|
||||
paddingLeft: left,
|
||||
paddingRight: right,
|
||||
paddingStart: left,
|
||||
paddingEnd: right,
|
||||
paddingTop: excludeTop ? 0 : top,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,8 +49,6 @@ const Searchbar: React.FC<SearcbarProps> = ({
|
||||
const [extraMenu, showExtraMenu] = useState(false);
|
||||
|
||||
const marginTop = 8;
|
||||
const marginRight = 16;
|
||||
const marginLeft = 16;
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -58,8 +56,6 @@ const Searchbar: React.FC<SearcbarProps> = ({
|
||||
styles.searchbarContainer,
|
||||
{
|
||||
marginTop,
|
||||
marginRight,
|
||||
marginLeft,
|
||||
backgroundColor: theme.surface2,
|
||||
},
|
||||
]}
|
||||
@@ -86,7 +82,7 @@ const Searchbar: React.FC<SearcbarProps> = ({
|
||||
ref={searchbarRef}
|
||||
style={[styles.textInput, { color: theme.onSurface }]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.onSurface}
|
||||
placeholderTextColor={theme.onSurfaceVariant}
|
||||
onChangeText={onChangeText}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
defaultValue={searchText}
|
||||
@@ -164,7 +160,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
searchbarContainer: {
|
||||
borderRadius: 50,
|
||||
borderRadius: 28,
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 16,
|
||||
minHeight: 56,
|
||||
|
||||
@@ -88,7 +88,7 @@ export function SegmentedControl<T extends string = string>({
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
height: 48,
|
||||
height: 40,
|
||||
},
|
||||
segment: {
|
||||
flex: 1,
|
||||
@@ -99,16 +99,16 @@ const styles = StyleSheet.create({
|
||||
segmentFirst: {
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
borderTopLeftRadius: 24,
|
||||
borderBottomLeftRadius: 24,
|
||||
borderTopLeftRadius: 20,
|
||||
borderBottomLeftRadius: 20,
|
||||
},
|
||||
segmentMiddle: {
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
segmentLast: {
|
||||
borderRightWidth: 1,
|
||||
borderTopRightRadius: 24,
|
||||
borderBottomRightRadius: 24,
|
||||
borderTopRightRadius: 20,
|
||||
borderBottomRightRadius: 20,
|
||||
},
|
||||
segmentPressable: {
|
||||
flex: 1,
|
||||
|
||||
@@ -10,73 +10,118 @@ import Animated, {
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
|
||||
// MD3 Switch dimensions
|
||||
const TRACK_WIDTH = 52;
|
||||
const TRACK_HEIGHT = 32;
|
||||
const TRACK_RADIUS = TRACK_HEIGHT / 2;
|
||||
const THUMB_SIZE_OFF = 16;
|
||||
const THUMB_SIZE_ON = 24;
|
||||
const TRACK_BORDER_WIDTH = 2;
|
||||
|
||||
// Thumb positions: centered vertically, padded from edges
|
||||
const THUMB_OFFSET_OFF = (TRACK_HEIGHT - THUMB_SIZE_OFF) / 2;
|
||||
const THUMB_OFFSET_ON =
|
||||
TRACK_WIDTH - THUMB_SIZE_ON - (TRACK_HEIGHT - THUMB_SIZE_ON) / 2;
|
||||
|
||||
interface SwitchProps {
|
||||
value: boolean;
|
||||
onValueChange?: () => void;
|
||||
size?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const Switch = ({ value, size = 22, onValueChange, style }: SwitchProps) => {
|
||||
const Switch = ({ value, onValueChange, style }: SwitchProps) => {
|
||||
const theme = useTheme();
|
||||
// Value for Switch Animation
|
||||
// const switchTranslate = useSharedValue(value ? size : size / 6);
|
||||
|
||||
// Background color animation progress
|
||||
const progress = useSharedValue(value ? size : 0);
|
||||
const progress = useSharedValue(value ? 1 : 0);
|
||||
|
||||
// Animate switch movement
|
||||
const customSpringStyles = useAnimatedStyle(() => ({
|
||||
useEffect(() => {
|
||||
progress.value = withTiming(value ? 1 : 0, { duration: 200 });
|
||||
}, [progress, value]);
|
||||
|
||||
// Animate thumb position
|
||||
const thumbPositionStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateX: withSpring(value ? size : size / 6, {
|
||||
mass: 1,
|
||||
damping: 15,
|
||||
stiffness: 120,
|
||||
overshootClamping: false,
|
||||
}),
|
||||
translateX: withSpring(
|
||||
value ? THUMB_OFFSET_ON : THUMB_OFFSET_OFF,
|
||||
{
|
||||
mass: 1,
|
||||
damping: 15,
|
||||
stiffness: 120,
|
||||
overshootClamping: false,
|
||||
},
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// Precompute background color animation
|
||||
const backgroundColor = useDerivedValue(
|
||||
// Animate thumb size
|
||||
const thumbSizeStyle = useAnimatedStyle(() => {
|
||||
const size = withSpring(value ? THUMB_SIZE_ON : THUMB_SIZE_OFF, {
|
||||
mass: 1,
|
||||
damping: 15,
|
||||
stiffness: 120,
|
||||
overshootClamping: false,
|
||||
});
|
||||
return {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size,
|
||||
};
|
||||
});
|
||||
|
||||
// Animate track background color
|
||||
const trackBgColor = useDerivedValue(
|
||||
() =>
|
||||
interpolateColor(
|
||||
progress.value,
|
||||
[0, size],
|
||||
[theme.outline, theme.primary],
|
||||
[0, 1],
|
||||
[theme.surfaceVariant, theme.primary],
|
||||
),
|
||||
[value],
|
||||
[theme.surfaceVariant, theme.primary],
|
||||
);
|
||||
|
||||
const backgroundColorStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: backgroundColor.value,
|
||||
const trackColorStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: trackBgColor.value,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
progress.value = withTiming(value ? size : 0);
|
||||
}, [progress, size, value]);
|
||||
// Animate track border
|
||||
const trackBorderStyle = useAnimatedStyle(() => ({
|
||||
borderWidth: withTiming(value ? 0 : TRACK_BORDER_WIDTH, { duration: 200 }),
|
||||
borderColor: theme.outline,
|
||||
}));
|
||||
|
||||
// Animate thumb color
|
||||
const thumbBgColor = useDerivedValue(
|
||||
() =>
|
||||
interpolateColor(
|
||||
progress.value,
|
||||
[0, 1],
|
||||
[theme.outline, theme.onPrimary],
|
||||
),
|
||||
[theme.outline, theme.onPrimary],
|
||||
);
|
||||
|
||||
const thumbColorStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: thumbBgColor.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Pressable onPress={onValueChange}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
styles.track,
|
||||
style,
|
||||
{
|
||||
width: size * 2 + size / 6,
|
||||
height: size + size / 3,
|
||||
borderRadius: size,
|
||||
},
|
||||
backgroundColorStyle,
|
||||
trackColorStyle,
|
||||
trackBorderStyle,
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.circle,
|
||||
customSpringStyles,
|
||||
{ height: size, width: size, borderRadius: size },
|
||||
styles.thumb,
|
||||
thumbPositionStyle,
|
||||
thumbSizeStyle,
|
||||
thumbColorStyle,
|
||||
]}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -87,18 +132,20 @@ const Switch = ({ value, size = 22, onValueChange, style }: SwitchProps) => {
|
||||
export default React.memo(Switch);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
circle: {
|
||||
backgroundColor: 'white',
|
||||
elevation: 4,
|
||||
thumb: {
|
||||
elevation: 1,
|
||||
shadowColor: 'black',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
height: 1,
|
||||
},
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 2.5,
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 1,
|
||||
},
|
||||
container: {
|
||||
track: {
|
||||
width: TRACK_WIDTH,
|
||||
height: TRACK_HEIGHT,
|
||||
borderRadius: TRACK_RADIUS,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ interface SwitchItemProps {
|
||||
description?: string;
|
||||
onPress: () => void;
|
||||
theme: ThemeColors;
|
||||
size?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
@@ -26,7 +25,6 @@ const SwitchItem: React.FC<SwitchItemProps> = ({
|
||||
onPress,
|
||||
theme,
|
||||
value,
|
||||
size,
|
||||
style,
|
||||
}) => (
|
||||
<Pressable
|
||||
@@ -46,7 +44,6 @@ const SwitchItem: React.FC<SwitchItemProps> = ({
|
||||
value={value}
|
||||
onValueChange={onPress}
|
||||
style={styles.switch}
|
||||
size={size}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -58,10 +55,11 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
label: {
|
||||
@@ -72,6 +70,6 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
switch: {
|
||||
marginLeft: 8,
|
||||
marginStart: 8,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ const styles = StyleSheet.create({
|
||||
checkIcon: {
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
right: 5,
|
||||
end: 5,
|
||||
borderRadius: 50,
|
||||
padding: 1.6,
|
||||
zIndex: 1,
|
||||
@@ -164,7 +164,7 @@ const styles = StyleSheet.create({
|
||||
topBarAccent: {
|
||||
width: 44,
|
||||
height: 10,
|
||||
marginLeft: 8,
|
||||
marginStart: 8,
|
||||
borderRadius: 50,
|
||||
},
|
||||
content: {
|
||||
@@ -186,7 +186,7 @@ const styles = StyleSheet.create({
|
||||
rowAccentSmall: {
|
||||
height: 10,
|
||||
width: 16,
|
||||
marginLeft: 4,
|
||||
marginStart: 4,
|
||||
borderRadius: 50,
|
||||
},
|
||||
rowAccentShort: {
|
||||
@@ -221,5 +221,5 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
marginLeft: { marginLeft: 4 },
|
||||
marginLeft: { marginStart: 4 },
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { BackupCategory, Category, NovelCategory, CCategory } from '../types';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { getString } from '@strings/translations';
|
||||
import { db } from '@database/db';
|
||||
import { getAllSync, runSync } from '@database/utils/helpers';
|
||||
|
||||
const getCategoriesQuery = `
|
||||
SELECT
|
||||
@@ -19,7 +17,7 @@ const getCategoriesQuery = `
|
||||
|
||||
type NumberList = `${number}` | `${number},${number}` | undefined;
|
||||
export const getCategoriesFromDb = () => {
|
||||
return getAllSync<Category & { novelIds: NumberList }>([getCategoriesQuery]);
|
||||
return db.getAllSync<Category & { novelIds: NumberList }>(getCategoriesQuery);
|
||||
};
|
||||
|
||||
export const getCategoriesWithCount = (novelIds: number[]) => {
|
||||
@@ -35,13 +33,14 @@ export const getCategoriesWithCount = (novelIds: number[]) => {
|
||||
WHERE Category.id != 2
|
||||
ORDER BY sort
|
||||
`;
|
||||
return getAllSync<CCategory>([getCategoriesWithCountQuery]);
|
||||
return db.getAllSync<CCategory>(getCategoriesWithCountQuery);
|
||||
};
|
||||
|
||||
const createCategoryQuery = 'INSERT INTO Category (name) VALUES (?)';
|
||||
|
||||
export const createCategory = (categoryName: string): void =>
|
||||
runSync([[createCategoryQuery, [categoryName]]]);
|
||||
export const createCategory = (categoryName: string): void => {
|
||||
db.runSync(createCategoryQuery, categoryName);
|
||||
};
|
||||
|
||||
const beforeDeleteCategoryQuery = `
|
||||
UPDATE NovelCategory SET categoryId = (SELECT id FROM Category WHERE sort = 1)
|
||||
@@ -58,10 +57,8 @@ export const deleteCategoryById = (category: Category): void => {
|
||||
if (category.sort === 1 || category.id === 2) {
|
||||
return showToast(getString('categories.cantDeleteDefault'));
|
||||
}
|
||||
runSync([
|
||||
[beforeDeleteCategoryQuery, [category.id]],
|
||||
[deleteCategoryQuery, [category.id]],
|
||||
]);
|
||||
db.runSync(beforeDeleteCategoryQuery, category.id);
|
||||
db.runSync(deleteCategoryQuery, category.id);
|
||||
};
|
||||
|
||||
const updateCategoryQuery = 'UPDATE Category SET name = ? WHERE id = ?';
|
||||
@@ -69,7 +66,9 @@ const updateCategoryQuery = 'UPDATE Category SET name = ? WHERE id = ?';
|
||||
export const updateCategory = (
|
||||
categoryId: number,
|
||||
categoryName: string,
|
||||
): void => runSync([[updateCategoryQuery, [categoryName, categoryId]]]);
|
||||
): void => {
|
||||
db.runSync(updateCategoryQuery, categoryName, categoryId);
|
||||
};
|
||||
|
||||
const isCategoryNameDuplicateQuery = `
|
||||
SELECT COUNT(*) as isDuplicate FROM Category WHERE name = ?
|
||||
@@ -92,31 +91,31 @@ export const updateCategoryOrderInDb = (categories: Category[]): void => {
|
||||
if (categories.length && categories[0].id === 2) {
|
||||
return;
|
||||
}
|
||||
runSync(
|
||||
categories.map(c => {
|
||||
return [updateCategoryOrderQuery, [c.sort, c.id]];
|
||||
}),
|
||||
);
|
||||
for (const c of categories) {
|
||||
db.runSync(updateCategoryOrderQuery, c.sort, c.id);
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllNovelCategories = () =>
|
||||
getAllSync<NovelCategory>(['SELECT * FROM NovelCategory']);
|
||||
db.getAllSync<NovelCategory>('SELECT * FROM NovelCategory');
|
||||
|
||||
export const _restoreCategory = (category: BackupCategory) => {
|
||||
const d = category.novelIds.map(novelId => [
|
||||
'INSERT OR IGNORE INTO NovelCategory (categoryId, novelId) VALUES (?, ?)',
|
||||
[category.id, novelId],
|
||||
]) as Array<[string, SQLite.SQLiteBindParams]>;
|
||||
|
||||
runSync([
|
||||
[
|
||||
'DELETE FROM Category WHERE id = ? OR sort = ?',
|
||||
[category.id, category.sort],
|
||||
],
|
||||
[
|
||||
'INSERT OR IGNORE INTO Category (id, name, sort) VALUES (?, ?, ?)',
|
||||
[category.id, category.name, category.sort],
|
||||
],
|
||||
...d,
|
||||
]);
|
||||
db.runSync(
|
||||
'DELETE FROM Category WHERE id = ? OR sort = ?',
|
||||
category.id,
|
||||
category.sort,
|
||||
);
|
||||
db.runSync(
|
||||
'INSERT OR IGNORE INTO Category (id, name, sort) VALUES (?, ?, ?)',
|
||||
category.id,
|
||||
category.name,
|
||||
category.sort,
|
||||
);
|
||||
for (const novelId of category.novelIds) {
|
||||
db.runSync(
|
||||
'INSERT OR IGNORE INTO NovelCategory (categoryId, novelId) VALUES (?, ?)',
|
||||
category.id,
|
||||
novelId,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,13 +22,13 @@ export const insertChapters = async (
|
||||
return;
|
||||
}
|
||||
|
||||
await db.withExclusiveTransactionAsync(async tx => {
|
||||
await db.withTransactionAsync(async () => {
|
||||
for (let index = 0; index < chapters.length; index++) {
|
||||
const chapter = chapters[index];
|
||||
const chapterName = chapter.name ?? 'Chapter ' + (index + 1);
|
||||
const chapterPage = chapter.page || '1';
|
||||
|
||||
const result = await tx.runAsync(
|
||||
const result = await db.runAsync(
|
||||
`
|
||||
INSERT INTO Chapter (path, name, releaseTime, novelId, chapterNumber, page, position)
|
||||
SELECT ?, ?, ?, ?, ?, ?, ?
|
||||
@@ -48,7 +48,7 @@ export const insertChapters = async (
|
||||
const insertId = result.lastInsertRowId;
|
||||
|
||||
if (!insertId || insertId < 0) {
|
||||
await tx.runAsync(
|
||||
await db.runAsync(
|
||||
`
|
||||
UPDATE Chapter SET
|
||||
page = ?, position = ?, name = ?, releaseTime = ?, chapterNumber = ?
|
||||
@@ -209,8 +209,8 @@ export const clearUpdates = () =>
|
||||
// #region Selectors
|
||||
|
||||
export const getCustomPages = (novelId: number) =>
|
||||
db.getAllSync<{ page: string }>(
|
||||
'SELECT DISTINCT page from Chapter WHERE novelId = ?',
|
||||
db.getAllAsync<{ page: string }>(
|
||||
'SELECT DISTINCT page from Chapter WHERE novelId = ? ORDER BY CAST(page AS INTEGER) ASC',
|
||||
novelId,
|
||||
);
|
||||
|
||||
@@ -272,11 +272,13 @@ export const getPageChapters = (
|
||||
);
|
||||
};
|
||||
|
||||
export const getChapterCount = (novelId: number, page: string = '1') =>
|
||||
db.getFirstSync<{ 'COUNT(*)': number }>(
|
||||
'SELECT COUNT(*) FROM Chapter WHERE novelId = ? AND page = ?',
|
||||
novelId,
|
||||
page,
|
||||
export const getChapterCount = async (novelId: number, page: string = '1') =>
|
||||
(
|
||||
await db.getFirstAsync<{ 'COUNT(*)': number }>(
|
||||
'SELECT COUNT(*) FROM Chapter WHERE novelId = ? AND page = ?',
|
||||
novelId,
|
||||
page,
|
||||
)
|
||||
)?.['COUNT(*)'] ?? 0;
|
||||
|
||||
export const getPageChaptersBatched = (
|
||||
@@ -286,26 +288,56 @@ export const getPageChaptersBatched = (
|
||||
page?: string,
|
||||
batch: number = 0,
|
||||
) => {
|
||||
return db.getAllSync<ChapterInfo>(
|
||||
return db.getAllAsync<ChapterInfo>(
|
||||
getPageChaptersQuery(sort, filter, 300, 300 * batch),
|
||||
novelId,
|
||||
page || '1',
|
||||
);
|
||||
};
|
||||
|
||||
export const getNovelChaptersByNumber = (
|
||||
novelId: number,
|
||||
chapterNumber: number,
|
||||
) => {
|
||||
return db.getAllAsync<ChapterInfo>(
|
||||
'SELECT * FROM Chapter WHERE novelId = ? AND position = ?',
|
||||
novelId,
|
||||
chapterNumber - 1,
|
||||
);
|
||||
};
|
||||
|
||||
export const getFirstUnreadChapter = (
|
||||
novelId: number,
|
||||
filter?: string,
|
||||
page?: string,
|
||||
) =>
|
||||
db.getFirstAsync<ChapterInfo>(
|
||||
`SELECT * FROM Chapter WHERE novelId = ? AND page = ? AND unread = 1 ${filter || ''} ORDER BY position ASC LIMIT 1`,
|
||||
novelId,
|
||||
page || '1',
|
||||
);
|
||||
|
||||
export const getNovelChaptersByName = (novelId: number, searchText: string) => {
|
||||
return db.getAllAsync<ChapterInfo>(
|
||||
'SELECT * FROM Chapter WHERE novelId = ? AND name LIKE ?',
|
||||
novelId,
|
||||
`%${searchText}%`,
|
||||
);
|
||||
};
|
||||
|
||||
export const getPrevChapter = (
|
||||
novelId: number,
|
||||
chapterPosition: number,
|
||||
page: string,
|
||||
) =>
|
||||
db.getFirstAsync<ChapterInfo>(
|
||||
`SELECT * FROM Chapter
|
||||
WHERE novelId = ?
|
||||
`SELECT * FROM Chapter
|
||||
WHERE novelId = ?
|
||||
AND (
|
||||
(position < ? AND page = ?)
|
||||
OR page < ?
|
||||
(position < ? AND page = ?)
|
||||
OR CAST(page AS INTEGER) < CAST(? AS INTEGER)
|
||||
)
|
||||
ORDER BY position DESC, page DESC`,
|
||||
ORDER BY CAST(page AS INTEGER) DESC, position DESC`,
|
||||
novelId,
|
||||
chapterPosition,
|
||||
page,
|
||||
@@ -318,13 +350,13 @@ export const getNextChapter = (
|
||||
page: string,
|
||||
) =>
|
||||
db.getFirstAsync<ChapterInfo>(
|
||||
`SELECT * FROM Chapter
|
||||
WHERE novelId = ?
|
||||
`SELECT * FROM Chapter
|
||||
WHERE novelId = ?
|
||||
AND (
|
||||
(page = ? AND position > ?)
|
||||
OR (position = 0 AND page > ?)
|
||||
(page = ? AND position > ?)
|
||||
OR (position = 0 AND CAST(page AS INTEGER) > CAST(? AS INTEGER))
|
||||
)
|
||||
ORDER BY position ASC, page ASC`,
|
||||
ORDER BY CAST(page AS INTEGER) ASC, position ASC`,
|
||||
novelId,
|
||||
page,
|
||||
chapterPosition,
|
||||
@@ -420,8 +452,8 @@ ORDER BY updatedTime DESC;
|
||||
return await result;
|
||||
};
|
||||
|
||||
export const isChapterDownloaded = (chapterId: number) =>
|
||||
!!db.getFirstSync<ChapterInfo>(
|
||||
export const isChapterDownloaded = async (chapterId: number) =>
|
||||
!!(await db.getFirstAsync<ChapterInfo>(
|
||||
'SELECT * FROM Chapter WHERE id = ? AND isDownloaded = 1',
|
||||
chapterId,
|
||||
);
|
||||
));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@database/db';
|
||||
import { LibraryNovelInfo, NovelInfo } from '../types';
|
||||
import { getAllSync } from '../utils/helpers';
|
||||
|
||||
export const getLibraryNovelsFromDb = (
|
||||
sortOrder?: string,
|
||||
@@ -7,7 +7,7 @@ export const getLibraryNovelsFromDb = (
|
||||
searchText?: string,
|
||||
downloadedOnlyMode?: boolean,
|
||||
excludeLocalNovels?: boolean,
|
||||
): NovelInfo[] => {
|
||||
) => {
|
||||
let query = 'SELECT * FROM Novel WHERE inLibrary = 1';
|
||||
|
||||
if (excludeLocalNovels) {
|
||||
@@ -30,25 +30,25 @@ export const getLibraryNovelsFromDb = (
|
||||
query += ` ORDER BY ${sortOrder}`;
|
||||
}
|
||||
|
||||
return getAllSync<NovelInfo>([query, [searchText ?? '']]);
|
||||
return db.getAllAsync<NovelInfo>(query, searchText ? `%${searchText}%` : '');
|
||||
};
|
||||
|
||||
const getNovelOfCategoryQuery =
|
||||
'SELECT DISTINCT novelId FROM NovelCategory WHERE 1 = 1';
|
||||
const getNovelsFromIDListQuery = 'SELECT * FROM Novel WHERE inLibrary = 1 ';
|
||||
|
||||
export const getLibraryWithCategory = (
|
||||
export const getLibraryWithCategory = async (
|
||||
categoryId?: number | null,
|
||||
onlyUpdateOngoingNovels?: boolean,
|
||||
excludeLocalNovels?: boolean,
|
||||
): LibraryNovelInfo[] => {
|
||||
): Promise<LibraryNovelInfo[]> => {
|
||||
let categoryQuery = getNovelOfCategoryQuery;
|
||||
|
||||
if (categoryId) {
|
||||
categoryQuery += ` AND categoryId = ${categoryId}`;
|
||||
}
|
||||
|
||||
const idRows = getAllSync<{ novelId: number }>([categoryQuery, []]);
|
||||
const idRows = await db.getAllAsync<{ novelId: number }>(categoryQuery);
|
||||
|
||||
if (!idRows || idRows.length === 0) return [];
|
||||
|
||||
@@ -66,7 +66,5 @@ export const getLibraryWithCategory = (
|
||||
novelQuery += " AND status = 'Ongoing'";
|
||||
}
|
||||
|
||||
const res = getAllSync<LibraryNovelInfo>([novelQuery, []]);
|
||||
|
||||
return res;
|
||||
return db.getAllAsync<LibraryNovelInfo>(novelQuery);
|
||||
};
|
||||
|
||||
@@ -4,14 +4,6 @@ import { fetchNovel } from '@services/plugin/fetch';
|
||||
import { insertChapters } from './ChapterQueries';
|
||||
|
||||
import { showToast } from '@utils/showToast';
|
||||
import {
|
||||
getAllAsync,
|
||||
getFirstAsync,
|
||||
getFirstSync,
|
||||
QueryObject,
|
||||
runAsync,
|
||||
runSync,
|
||||
} from '../utils/helpers';
|
||||
import { getString } from '@strings/translations';
|
||||
import { BackupNovel, NovelInfo } from '../types';
|
||||
import { SourceNovel } from '@plugins/types';
|
||||
@@ -27,18 +19,20 @@ export const insertNovelAndChapters = async (
|
||||
): Promise<number | undefined> => {
|
||||
const insertNovelQuery =
|
||||
'INSERT OR IGNORE INTO Novel (path, pluginId, name, cover, summary, author, artist, status, genres, totalPages) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
||||
const novelId: number | undefined = db.runSync(insertNovelQuery, [
|
||||
sourceNovel.path,
|
||||
pluginId,
|
||||
sourceNovel.name,
|
||||
sourceNovel.cover || null,
|
||||
sourceNovel.summary || null,
|
||||
sourceNovel.author || null,
|
||||
sourceNovel.artist || null,
|
||||
sourceNovel.status || null,
|
||||
sourceNovel.genres || null,
|
||||
sourceNovel.totalPages || 0,
|
||||
]).lastInsertRowId;
|
||||
const novelId: number | undefined = (
|
||||
await db.runAsync(insertNovelQuery, [
|
||||
sourceNovel.path,
|
||||
pluginId,
|
||||
sourceNovel.name,
|
||||
sourceNovel.cover || null,
|
||||
sourceNovel.summary || null,
|
||||
sourceNovel.author || null,
|
||||
sourceNovel.artist || null,
|
||||
sourceNovel.status || null,
|
||||
sourceNovel.genres || null,
|
||||
sourceNovel.totalPages || 0,
|
||||
])
|
||||
).lastInsertRowId;
|
||||
|
||||
if (novelId) {
|
||||
if (sourceNovel.cover) {
|
||||
@@ -50,43 +44,38 @@ export const insertNovelAndChapters = async (
|
||||
sourceNovel.cover,
|
||||
novelCoverPath,
|
||||
getPlugin(pluginId)?.imageRequestInit,
|
||||
).then(() => {
|
||||
runSync([
|
||||
[
|
||||
'UPDATE Novel SET cover = ? WHERE id = ?',
|
||||
[novelCoverUri, novelId!],
|
||||
],
|
||||
]);
|
||||
});
|
||||
);
|
||||
await db.runAsync(
|
||||
'UPDATE Novel SET cover = ? WHERE id = ?',
|
||||
novelCoverUri,
|
||||
novelId,
|
||||
);
|
||||
}
|
||||
await insertChapters(novelId, sourceNovel.chapters);
|
||||
}
|
||||
return novelId;
|
||||
};
|
||||
|
||||
export const getAllNovels = async () => {
|
||||
return getAllAsync<NovelInfo>(['SELECT * FROM Novel']);
|
||||
export const getAllNovels = () => {
|
||||
return db.getAllAsync<NovelInfo>('SELECT * FROM Novel');
|
||||
};
|
||||
|
||||
export const getNovelById = async (novelId: number) => {
|
||||
return getFirstAsync<NovelInfo>([
|
||||
export const getNovelById = (novelId: number) => {
|
||||
return db.getFirstAsync<NovelInfo>(
|
||||
'SELECT * FROM Novel WHERE id = ?',
|
||||
[novelId],
|
||||
]);
|
||||
novelId,
|
||||
);
|
||||
};
|
||||
|
||||
export const getNovelByPath = (
|
||||
novelPath: string,
|
||||
pluginId: string,
|
||||
): NovelInfo | undefined => {
|
||||
const res = getFirstSync<NovelInfo>([
|
||||
) => {
|
||||
return db.getFirstAsync<NovelInfo>(
|
||||
'SELECT * FROM Novel WHERE path = ? AND pluginId = ?',
|
||||
[novelPath, pluginId],
|
||||
]);
|
||||
if (!res) {
|
||||
return undefined;
|
||||
}
|
||||
return res;
|
||||
novelPath,
|
||||
pluginId,
|
||||
);
|
||||
};
|
||||
|
||||
// if query is insert novel || add to library => add default category name for it
|
||||
@@ -98,83 +87,75 @@ export const switchNovelToLibraryQuery = async (
|
||||
): Promise<NovelInfo | undefined> => {
|
||||
const novel = await getNovelByPath(novelPath, pluginId);
|
||||
if (novel) {
|
||||
const queries: QueryObject[] = [
|
||||
[
|
||||
'UPDATE Novel SET inLibrary = ? WHERE id = ?',
|
||||
[Number(!novel.inLibrary), novel.id],
|
||||
],
|
||||
novel.inLibrary
|
||||
? [
|
||||
'DELETE FROM NovelCategory WHERE novelId = ?',
|
||||
[novel.id],
|
||||
() => showToast(getString('browseScreen.removeFromLibrary')),
|
||||
]
|
||||
: [
|
||||
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, (SELECT DISTINCT id FROM Category WHERE sort = 1))',
|
||||
[novel.id],
|
||||
() => showToast(getString('browseScreen.addedToLibrary')),
|
||||
],
|
||||
];
|
||||
if (novel.pluginId === 'local') {
|
||||
queries.push([
|
||||
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, 2)',
|
||||
[novel.id],
|
||||
]);
|
||||
await db.runAsync(
|
||||
'UPDATE Novel SET inLibrary = ? WHERE id = ?',
|
||||
Number(!novel.inLibrary),
|
||||
novel.id,
|
||||
);
|
||||
if (novel.inLibrary) {
|
||||
await db.runAsync(
|
||||
'DELETE FROM NovelCategory WHERE novelId = ?',
|
||||
novel.id,
|
||||
);
|
||||
showToast(getString('browseScreen.removeFromLibrary'));
|
||||
} else {
|
||||
await db.runAsync(
|
||||
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, (SELECT DISTINCT id FROM Category WHERE sort = 1))',
|
||||
novel.id,
|
||||
);
|
||||
showToast(getString('browseScreen.addedToLibrary'));
|
||||
}
|
||||
if (novel.pluginId === 'local') {
|
||||
await db.runAsync(
|
||||
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, 2)',
|
||||
novel.id,
|
||||
);
|
||||
}
|
||||
await runAsync(queries);
|
||||
return { ...novel, inLibrary: !novel.inLibrary };
|
||||
} else {
|
||||
const sourceNovel = await fetchNovel(pluginId, novelPath);
|
||||
const novelId = await insertNovelAndChapters(pluginId, sourceNovel);
|
||||
if (novelId) {
|
||||
await runAsync([
|
||||
[
|
||||
'UPDATE Novel SET inLibrary = 1 WHERE id = ?',
|
||||
[novelId],
|
||||
() => showToast(getString('browseScreen.addedToLibrary')),
|
||||
],
|
||||
[
|
||||
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, (SELECT DISTINCT id FROM Category WHERE sort = 1))',
|
||||
[novelId],
|
||||
],
|
||||
]);
|
||||
await db.runAsync('UPDATE Novel SET inLibrary = 1 WHERE id = ?', novelId);
|
||||
await db.runAsync(
|
||||
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, (SELECT DISTINCT id FROM Category WHERE sort = 1))',
|
||||
novelId,
|
||||
);
|
||||
showToast(getString('browseScreen.addedToLibrary'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// allow to delete local novels
|
||||
export const removeNovelsFromLibrary = (novelIds: Array<number>) => {
|
||||
runSync([
|
||||
[`UPDATE Novel SET inLibrary = 0 WHERE id IN (${novelIds.join(', ')});`],
|
||||
[`DELETE FROM NovelCategory WHERE novelId IN (${novelIds.join(', ')});`],
|
||||
]);
|
||||
export const removeNovelsFromLibrary = async (novelIds: Array<number>) => {
|
||||
await db.runAsync(
|
||||
`UPDATE Novel SET inLibrary = 0 WHERE id IN (${novelIds.join(', ')})`,
|
||||
);
|
||||
await db.runAsync(
|
||||
`DELETE FROM NovelCategory WHERE novelId IN (${novelIds.join(', ')})`,
|
||||
);
|
||||
showToast(getString('browseScreen.removeFromLibrary'));
|
||||
};
|
||||
|
||||
export const getCachedNovels = () => {
|
||||
return getAllAsync<NovelInfo>(['SELECT * FROM Novel WHERE inLibrary = 0']);
|
||||
return db.getAllAsync<NovelInfo>('SELECT * FROM Novel WHERE inLibrary = 0');
|
||||
};
|
||||
|
||||
export const deleteCachedNovels = async () => {
|
||||
runAsync([
|
||||
[
|
||||
'DELETE FROM Novel WHERE inLibrary = 0',
|
||||
[],
|
||||
() =>
|
||||
showToast(getString('advancedSettingsScreen.cachedNovelsDeletedToast')),
|
||||
],
|
||||
]);
|
||||
await db.runAsync('DELETE FROM Novel WHERE inLibrary = 0');
|
||||
showToast(getString('advancedSettingsScreen.cachedNovelsDeletedToast'));
|
||||
};
|
||||
|
||||
const restoreFromBackupQuery =
|
||||
'INSERT OR REPLACE INTO Novel (path, name, pluginId, cover, summary, author, artist, status, genres, totalPages) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
||||
|
||||
export const restoreLibrary = async (novel: NovelInfo) => {
|
||||
const sourceNovel = await fetchNovel(novel.pluginId, novel.path).catch(e => {
|
||||
throw e;
|
||||
});
|
||||
const sourceNovel = await fetchNovel(novel.pluginId, novel.path);
|
||||
let novelId: number | undefined;
|
||||
|
||||
await db.withTransactionAsync(async () => {
|
||||
db.runAsync(restoreFromBackupQuery, [
|
||||
const result = await db.runAsync(
|
||||
restoreFromBackupQuery,
|
||||
sourceNovel.path,
|
||||
novel.name,
|
||||
novel.pluginId,
|
||||
@@ -185,32 +166,17 @@ export const restoreLibrary = async (novel: NovelInfo) => {
|
||||
novel.status || '',
|
||||
novel.genres || '',
|
||||
sourceNovel.totalPages || 0,
|
||||
]).then(data => {
|
||||
novelId = data.lastInsertRowId;
|
||||
});
|
||||
);
|
||||
novelId = result.lastInsertRowId;
|
||||
});
|
||||
|
||||
if (novelId && novelId > 0) {
|
||||
await new Promise((resolve, reject) => {
|
||||
runAsync([
|
||||
[
|
||||
'INSERT OR REPLACE INTO NovelCategory (novelId, categoryId) VALUES (?, (SELECT DISTINCT id FROM Category WHERE sort = 1))',
|
||||
[novelId!],
|
||||
() => {
|
||||
db.runAsync('UPDATE Novel SET inLibrary = 1 WHERE id = ?', [
|
||||
novelId!,
|
||||
]);
|
||||
resolve(null);
|
||||
},
|
||||
() => {
|
||||
reject(null);
|
||||
return false;
|
||||
},
|
||||
],
|
||||
]);
|
||||
}).catch(e => {
|
||||
throw e;
|
||||
});
|
||||
await db.runAsync(
|
||||
'INSERT OR REPLACE INTO NovelCategory (novelId, categoryId) VALUES (?, (SELECT DISTINCT id FROM Category WHERE sort = 1))',
|
||||
novelId,
|
||||
);
|
||||
await db.runAsync('UPDATE Novel SET inLibrary = 1 WHERE id = ?', novelId);
|
||||
|
||||
if (sourceNovel.chapters) {
|
||||
await insertChapters(novelId, sourceNovel.chapters);
|
||||
}
|
||||
@@ -218,23 +184,19 @@ export const restoreLibrary = async (novel: NovelInfo) => {
|
||||
};
|
||||
|
||||
export const updateNovelInfo = async (info: NovelInfo) => {
|
||||
runAsync([
|
||||
[
|
||||
'UPDATE Novel SET name = ?, cover = ?, path = ?, summary = ?, author = ?, artist = ?, genres = ?, status = ?, isLocal = ? WHERE id = ?',
|
||||
[
|
||||
info.name,
|
||||
info.cover || '',
|
||||
info.path,
|
||||
info.summary || '',
|
||||
info.author || '',
|
||||
info.artist || '',
|
||||
info.genres || '',
|
||||
info.status || '',
|
||||
Number(info.isLocal),
|
||||
info.id,
|
||||
],
|
||||
],
|
||||
]);
|
||||
await db.runAsync(
|
||||
'UPDATE Novel SET name = ?, cover = ?, path = ?, summary = ?, author = ?, artist = ?, genres = ?, status = ?, isLocal = ? WHERE id = ?',
|
||||
info.name,
|
||||
info.cover || '',
|
||||
info.path,
|
||||
info.summary || '',
|
||||
info.author || '',
|
||||
info.artist || '',
|
||||
info.genres || '',
|
||||
info.status || '',
|
||||
Number(info.isLocal),
|
||||
info.id,
|
||||
);
|
||||
};
|
||||
|
||||
export const pickCustomNovelCover = async (novel: NovelInfo) => {
|
||||
@@ -247,9 +209,11 @@ export const pickCustomNovelCover = async (novel: NovelInfo) => {
|
||||
}
|
||||
NativeFile.copyFile(image.assets[0].uri, novelCoverUri);
|
||||
novelCoverUri += '?' + Date.now();
|
||||
runAsync([
|
||||
['UPDATE Novel SET cover = ? WHERE id = ?', [novelCoverUri, novel.id]],
|
||||
]);
|
||||
await db.runAsync(
|
||||
'UPDATE Novel SET cover = ? WHERE id = ?',
|
||||
novelCoverUri,
|
||||
novel.id,
|
||||
);
|
||||
return novelCoverUri;
|
||||
}
|
||||
};
|
||||
@@ -258,48 +222,45 @@ export const updateNovelCategoryById = async (
|
||||
novelId: number,
|
||||
categoryIds: number[],
|
||||
) => {
|
||||
runAsync(
|
||||
categoryIds.map(categoryId => {
|
||||
return [
|
||||
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, ?)',
|
||||
[novelId, categoryId],
|
||||
];
|
||||
}),
|
||||
);
|
||||
for (const categoryId of categoryIds) {
|
||||
await db.runAsync(
|
||||
'INSERT INTO NovelCategory (novelId, categoryId) VALUES (?, ?)',
|
||||
novelId,
|
||||
categoryId,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateNovelCategories = async (
|
||||
novelIds: number[],
|
||||
categoryIds: number[],
|
||||
): Promise<void> => {
|
||||
const queries: QueryObject[] = [];
|
||||
queries.push([
|
||||
await db.runAsync(
|
||||
`DELETE FROM NovelCategory WHERE novelId IN (${novelIds.join(
|
||||
',',
|
||||
)}) AND categoryId != 2`,
|
||||
]);
|
||||
);
|
||||
// if no category is selected => set to the default category
|
||||
if (categoryIds.length) {
|
||||
novelIds.forEach(novelId => {
|
||||
categoryIds.forEach(categoryId =>
|
||||
queries.push([
|
||||
for (const novelId of novelIds) {
|
||||
for (const categoryId of categoryIds) {
|
||||
await db.runAsync(
|
||||
`INSERT INTO NovelCategory (novelId, categoryId) VALUES (${novelId}, ${categoryId})`,
|
||||
]),
|
||||
);
|
||||
});
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
novelIds.forEach(novelId => {
|
||||
for (const novelId of novelIds) {
|
||||
// hacky: insert local novel category -> failed -> ignored
|
||||
queries.push([
|
||||
`INSERT OR IGNORE INTO NovelCategory (novelId, categoryId)
|
||||
await db.runAsync(
|
||||
`INSERT OR IGNORE INTO NovelCategory (novelId, categoryId)
|
||||
VALUES (
|
||||
${novelId},
|
||||
${novelId},
|
||||
IFNULL((SELECT categoryId FROM NovelCategory WHERE novelId = ${novelId}), (SELECT id FROM Category WHERE sort = 1))
|
||||
)`,
|
||||
]);
|
||||
});
|
||||
);
|
||||
}
|
||||
}
|
||||
return runSync(queries);
|
||||
};
|
||||
|
||||
const restoreObjectQuery = (table: string, obj: any) => {
|
||||
@@ -314,19 +275,17 @@ const restoreObjectQuery = (table: string, obj: any) => {
|
||||
|
||||
export const _restoreNovelAndChapters = async (backupNovel: BackupNovel) => {
|
||||
const { chapters, ...novel } = backupNovel;
|
||||
await runAsync([
|
||||
['DELETE FROM Novel WHERE id = ?', [novel.id]],
|
||||
[
|
||||
restoreObjectQuery('Novel', novel),
|
||||
Object.values(novel) as string[] | number[],
|
||||
],
|
||||
]);
|
||||
await db.runAsync('DELETE FROM Novel WHERE id = ?', novel.id);
|
||||
await db.runAsync(
|
||||
restoreObjectQuery('Novel', novel),
|
||||
...(Object.values(novel) as (string | number)[]),
|
||||
);
|
||||
if (chapters.length > 0) {
|
||||
await runAsync(
|
||||
chapters.map(chapter => [
|
||||
for (const chapter of chapters) {
|
||||
await db.runAsync(
|
||||
restoreObjectQuery('Chapter', chapter),
|
||||
Object.values(chapter) as string[] | number[],
|
||||
]),
|
||||
);
|
||||
...(Object.values(chapter) as (string | number)[]),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { countBy } from 'lodash-es';
|
||||
import { db } from '@database/db';
|
||||
import { LibraryStats } from '../types';
|
||||
import { getAllAsync, getFirstAsync } from '../utils/helpers';
|
||||
|
||||
const getLibraryStatsQuery = `
|
||||
SELECT COUNT(*) as novelsCount, COUNT(DISTINCT pluginId) as sourcesCount
|
||||
@@ -52,51 +52,48 @@ const getNovelStatusQuery = `
|
||||
WHERE Novel.inLibrary = 1
|
||||
`;
|
||||
|
||||
export const getLibraryStatsFromDb = async (): Promise<LibraryStats> => {
|
||||
return getFirstAsync([getLibraryStatsQuery]) as any;
|
||||
export const getLibraryStatsFromDb = (): Promise<LibraryStats | null> => {
|
||||
return db.getFirstAsync<LibraryStats>(getLibraryStatsQuery);
|
||||
};
|
||||
|
||||
export const getChaptersTotalCountFromDb = async (): Promise<LibraryStats> => {
|
||||
return getFirstAsync([getChaptersTotalCountQuery]) as any;
|
||||
export const getChaptersTotalCountFromDb = (): Promise<LibraryStats | null> => {
|
||||
return db.getFirstAsync<LibraryStats>(getChaptersTotalCountQuery);
|
||||
};
|
||||
|
||||
export const getChaptersReadCountFromDb = async (): Promise<LibraryStats> => {
|
||||
return getFirstAsync([getChaptersReadCountQuery]) as any;
|
||||
export const getChaptersReadCountFromDb = (): Promise<LibraryStats | null> => {
|
||||
return db.getFirstAsync<LibraryStats>(getChaptersReadCountQuery);
|
||||
};
|
||||
|
||||
export const getChaptersUnreadCountFromDb = async (): Promise<LibraryStats> => {
|
||||
return getFirstAsync([getChaptersUnreadCountQuery]) as any;
|
||||
};
|
||||
export const getChaptersUnreadCountFromDb =
|
||||
(): Promise<LibraryStats | null> => {
|
||||
return db.getFirstAsync<LibraryStats>(getChaptersUnreadCountQuery);
|
||||
};
|
||||
|
||||
export const getChaptersDownloadedCountFromDb =
|
||||
async (): Promise<LibraryStats> => {
|
||||
return getFirstAsync([getChaptersDownloadedCountQuery]) as any;
|
||||
(): Promise<LibraryStats | null> => {
|
||||
return db.getFirstAsync<LibraryStats>(getChaptersDownloadedCountQuery);
|
||||
};
|
||||
|
||||
export const getNovelGenresFromDb = async (): Promise<LibraryStats> => {
|
||||
const genres: string[] = [];
|
||||
await getAllAsync([getNovelGenresQuery]).then(res => {
|
||||
(res as any).forEach((item: { genres: string }) => {
|
||||
const novelGenres = item.genres?.split(/\s*,\s*/);
|
||||
|
||||
if (novelGenres?.length) {
|
||||
genres.push(...novelGenres);
|
||||
}
|
||||
});
|
||||
const res = await db.getAllAsync<{ genres: string }>(getNovelGenresQuery);
|
||||
res.forEach(item => {
|
||||
const novelGenres = item.genres?.split(/\s*,\s*/);
|
||||
if (novelGenres?.length) {
|
||||
genres.push(...novelGenres);
|
||||
}
|
||||
});
|
||||
return { genres: countBy(genres) };
|
||||
};
|
||||
|
||||
export const getNovelStatusFromDb = async (): Promise<LibraryStats> => {
|
||||
const status: string[] = [];
|
||||
await getAllAsync([getNovelStatusQuery]).then(res => {
|
||||
(res as any).forEach((item: { status: string }) => {
|
||||
const novelStatus = item.status?.split(/\s*,\s*/);
|
||||
|
||||
if (novelStatus?.length) {
|
||||
status.push(...novelStatus);
|
||||
}
|
||||
});
|
||||
const res = await db.getAllAsync<{ status: string }>(getNovelStatusQuery);
|
||||
res.forEach(item => {
|
||||
const novelStatus = item.status?.split(/\s*,\s*/);
|
||||
if (novelStatus?.length) {
|
||||
status.push(...novelStatus);
|
||||
}
|
||||
});
|
||||
return { status: countBy(status) };
|
||||
};
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { db } from '@database/db';
|
||||
import {
|
||||
SQLiteBindParams,
|
||||
SQLiteBindValue,
|
||||
SQLiteRunResult,
|
||||
} from 'expo-sqlite';
|
||||
import { noop } from 'lodash-es';
|
||||
|
||||
function logError(error: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
type query = string;
|
||||
type SQLiteResultFunction<T> = (data: T) => void;
|
||||
type SQLiteErrorFunction = (data: any) => void;
|
||||
|
||||
export type QueryObject<T = unknown> =
|
||||
| [query, SQLiteBindParams, SQLiteResultFunction<T>, SQLiteErrorFunction]
|
||||
| [query, SQLiteBindParams, SQLiteResultFunction<T>]
|
||||
| [query, SQLiteBindParams]
|
||||
| [query];
|
||||
|
||||
function defaultQuerySync<T = unknown, Array extends boolean = false>(
|
||||
fn: 'getAllSync' | 'getFirstSync' | 'runSync',
|
||||
queryObject: QueryObject<Array extends true ? T[] : T>,
|
||||
errorReturn: Array extends true ? [] : null,
|
||||
) {
|
||||
const [query, params = [], callback = noop, catchCallback = logError] =
|
||||
queryObject;
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
const result = db[fn](query, params) as Array extends true ? T[] : T;
|
||||
callback(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
catchCallback(e);
|
||||
return errorReturn;
|
||||
}
|
||||
}
|
||||
async function defaultQueryAsync<T = unknown, Array extends boolean = false>(
|
||||
fn: 'getAllAsync' | 'getFirstAsync' | 'runAsync',
|
||||
queryObject: QueryObject<Array extends true ? T[] : T>,
|
||||
errorReturn: Array extends true ? [] : null,
|
||||
) {
|
||||
const [query, params = [], callback = noop, catchCallback = logError] =
|
||||
queryObject;
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
const result = (await db[fn](query, params)) as Array extends true
|
||||
? T[]
|
||||
: T;
|
||||
callback(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
catchCallback(e);
|
||||
return errorReturn;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAsync(queryObjects: QueryObject<SQLiteRunResult>[]) {
|
||||
const promises = [];
|
||||
for (const queryObject of queryObjects) {
|
||||
promises.push(
|
||||
defaultQueryAsync<SQLiteRunResult, false>('runAsync', queryObject, null),
|
||||
);
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
export function runSync(queryObjects: QueryObject<SQLiteRunResult>[]) {
|
||||
for (const queryObject of queryObjects) {
|
||||
defaultQuerySync<SQLiteRunResult, false>('runSync', queryObject, null);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllAsync<T = unknown>(queryObject: QueryObject<T[]>) {
|
||||
return defaultQueryAsync<T, true>('getAllAsync', queryObject, []);
|
||||
}
|
||||
export function getAllSync<T = unknown>(queryObject: QueryObject<T[]>) {
|
||||
return defaultQuerySync<T, true>('getAllSync', queryObject, []);
|
||||
}
|
||||
|
||||
export function getFirstAsync<T = unknown>(queryObject: QueryObject<T>) {
|
||||
return defaultQueryAsync<T, false>('getFirstAsync', queryObject, null);
|
||||
}
|
||||
export function getFirstSync<T = unknown>(queryObject: QueryObject<T>) {
|
||||
return defaultQuerySync<T, false>('getFirstSync', queryObject, null);
|
||||
}
|
||||
|
||||
type params = SQLiteBindValue[];
|
||||
type TransactionObject = [query, ...params];
|
||||
|
||||
export async function transactionAsync(transactionObject: TransactionObject[]) {
|
||||
await db.withTransactionAsync(async () => {
|
||||
const promises = transactionObject.map(async ([query, ...params]) => {
|
||||
db.runAsync(query, ...params);
|
||||
});
|
||||
await Promise.all(promises);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { version } from '../../../package.json';
|
||||
import { newer } from '@utils/compareVersion';
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
@@ -30,7 +30,7 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
|
||||
return timeSinceLastCheck >= ONE_DAY_MS;
|
||||
};
|
||||
|
||||
const checkForRelease = async () => {
|
||||
const checkForRelease = useCallback(async () => {
|
||||
if (!shouldCheckForUpdate()) {
|
||||
setChecking(false);
|
||||
return;
|
||||
@@ -65,11 +65,11 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
|
||||
// Silently fail in offline mode or on network errors
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isNewVersion = (versionTag: string) => {
|
||||
const currentVersion = `${version}`;
|
||||
const regex = /[^\\d.]/;
|
||||
const regex = /[^\d.]/;
|
||||
|
||||
const newVersion = versionTag.replace(regex, '');
|
||||
|
||||
@@ -78,7 +78,7 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
|
||||
|
||||
useEffect(() => {
|
||||
checkForRelease();
|
||||
}, []);
|
||||
}, [checkForRelease]);
|
||||
|
||||
if (!checking && latestRelease?.tag_name) {
|
||||
return {
|
||||
|
||||
@@ -20,6 +20,11 @@ export default function useDownload() {
|
||||
[queue],
|
||||
) as { task: DownloadChapterTask; meta: BackgroundTaskMetadata }[];
|
||||
|
||||
const downloadingChapterIds = useMemo(
|
||||
() => new Set(downloadQueue.map(c => c.task.data.chapterId)),
|
||||
[downloadQueue],
|
||||
);
|
||||
|
||||
const downloadChapter = (novel: NovelInfo, chapter: ChapterInfo) =>
|
||||
ServiceManager.manager.addTask({
|
||||
name: 'DOWNLOAD_CHAPTER',
|
||||
@@ -49,6 +54,7 @@ export default function useDownload() {
|
||||
|
||||
return {
|
||||
downloadQueue,
|
||||
downloadingChapterIds,
|
||||
resumeDowndload,
|
||||
downloadChapter,
|
||||
downloadChapters,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
getCustomPages,
|
||||
getChapterCount,
|
||||
getPageChaptersBatched,
|
||||
getFirstUnreadChapter as _getFirstUnreadChapter,
|
||||
updateChapterProgress as _updateChapterProgress,
|
||||
} from '@database/queries/ChapterQueries';
|
||||
import { fetchNovel, fetchPage } from '@services/plugin/fetch';
|
||||
@@ -66,9 +67,15 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
const [novel, setNovel] = useState<NovelInfo | undefined>(
|
||||
typeof novelOrPath === 'object' ? novelOrPath : undefined,
|
||||
);
|
||||
const [pages, setPages] = useState<string[]>(
|
||||
novel ? calculatePages(novel) : [],
|
||||
);
|
||||
const [pages, setPages] = useState<string[]>(() => {
|
||||
if (novel && novel.totalPages > 0) {
|
||||
const tmpPages = Array(novel.totalPages)
|
||||
.fill(0)
|
||||
.map((_, idx) => String(idx + 1));
|
||||
return tmpPages.length > 1 ? tmpPages : ['1'];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const { defaultChapterSort } = useAppSettings();
|
||||
|
||||
@@ -88,32 +95,27 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
);
|
||||
|
||||
const [chapters, _setChapters] = useState<ChapterInfo[]>([]);
|
||||
const [firstUnreadChapter, setFirstUnreadChapter] = useState<
|
||||
ChapterInfo | undefined
|
||||
>();
|
||||
const [batchInformation, setBatchInformation] = useState<{
|
||||
batch: number;
|
||||
total: number;
|
||||
totalChapters?: number;
|
||||
}>(
|
||||
typeof novelOrPath === 'object'
|
||||
? {
|
||||
batch: 0,
|
||||
total: 0,
|
||||
totalChapters: getChapterCount(novelOrPath.id, pages[pageIndex]),
|
||||
}
|
||||
: { batch: 0, total: 0 },
|
||||
);
|
||||
}>({ batch: 0, total: 0 });
|
||||
|
||||
const settingsSort = novelSettings.sort || defaultChapterSort;
|
||||
// #endregion
|
||||
// #region setters
|
||||
|
||||
function calculatePages(tmpNovel: NovelInfo) {
|
||||
async function calculatePages(tmpNovel: NovelInfo) {
|
||||
let tmpPages: string[];
|
||||
if (tmpNovel.totalPages > 0) {
|
||||
tmpPages = Array(tmpNovel.totalPages)
|
||||
.fill(0)
|
||||
.map((_, idx) => String(idx + 1));
|
||||
} else {
|
||||
tmpPages = getCustomPages(tmpNovel.id).map(c => c.page);
|
||||
tmpPages = (await getCustomPages(tmpNovel.id)).map(c => c.page);
|
||||
}
|
||||
|
||||
return tmpPages.length > 1 ? tmpPages : ['1'];
|
||||
@@ -132,8 +134,9 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
(index: number, update: Partial<ChapterInfo>) => {
|
||||
if (novel) {
|
||||
_setChapters(chs => {
|
||||
chs[index] = { ...chs[index], ...update };
|
||||
return chs;
|
||||
const next = [...chs];
|
||||
next[index] = { ...next[index], ...update };
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -218,20 +221,21 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
// #region getters
|
||||
|
||||
const getNovel = useCallback(async () => {
|
||||
let tmpNovel = getNovelByPath(novelPath, pluginId);
|
||||
let tmpNovel = await getNovelByPath(novelPath, pluginId);
|
||||
if (!tmpNovel) {
|
||||
const sourceNovel = await fetchNovel(pluginId, novelPath).catch(() => {
|
||||
throw new Error(getString('updatesScreen.unableToGetNovel'));
|
||||
});
|
||||
|
||||
await insertNovelAndChapters(pluginId, sourceNovel);
|
||||
tmpNovel = getNovelByPath(novelPath, pluginId);
|
||||
tmpNovel = await getNovelByPath(novelPath, pluginId);
|
||||
|
||||
if (!tmpNovel) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPages(calculatePages(tmpNovel));
|
||||
|
||||
setPages(await calculatePages(tmpNovel));
|
||||
|
||||
setNovel(tmpNovel);
|
||||
}, [novelPath, pluginId]);
|
||||
@@ -249,11 +253,11 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
page,
|
||||
] as const;
|
||||
|
||||
let chapterCount = getChapterCount(novel.id, page);
|
||||
let chapterCount = await getChapterCount(novel.id, page);
|
||||
|
||||
if (chapterCount) {
|
||||
try {
|
||||
newChapters = getPageChaptersBatched(...config) || [];
|
||||
newChapters = (await getPageChaptersBatched(...config)) || [];
|
||||
} catch (error) {
|
||||
console.error('teaser', error);
|
||||
}
|
||||
@@ -270,7 +274,7 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
});
|
||||
await insertChapters(novel.id, sourceChapters);
|
||||
newChapters = await _getPageChapters(...config);
|
||||
chapterCount = getChapterCount(novel.id, page);
|
||||
chapterCount = await getChapterCount(novel.id, page);
|
||||
}
|
||||
|
||||
setBatchInformation({
|
||||
@@ -279,6 +283,13 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
totalChapters: chapterCount,
|
||||
});
|
||||
setChapters(newChapters);
|
||||
|
||||
const unread = await _getFirstUnreadChapter(
|
||||
novel.id,
|
||||
novelSettings.filter,
|
||||
page,
|
||||
);
|
||||
setFirstUnreadChapter(unread ?? undefined);
|
||||
}
|
||||
}, [
|
||||
novel,
|
||||
@@ -291,7 +302,7 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
settingsSort,
|
||||
]);
|
||||
|
||||
const getNextChapterBatch = useCallback(() => {
|
||||
const getNextChapterBatch = useCallback(async () => {
|
||||
const page = pages[pageIndex];
|
||||
const nextBatch = batchInformation.batch + 1;
|
||||
if (novel && page && nextBatch <= batchInformation.total) {
|
||||
@@ -299,13 +310,13 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
|
||||
try {
|
||||
newChapters =
|
||||
getPageChaptersBatched(
|
||||
(await getPageChaptersBatched(
|
||||
novel.id,
|
||||
settingsSort,
|
||||
novelSettings.filter,
|
||||
page,
|
||||
nextBatch,
|
||||
) || [];
|
||||
)) || [];
|
||||
} catch (error) {
|
||||
console.error('teaser', error);
|
||||
}
|
||||
@@ -322,6 +333,50 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
settingsSort,
|
||||
]);
|
||||
|
||||
const loadUpToBatch = useCallback(
|
||||
async (targetBatch: number) => {
|
||||
const page = pages[pageIndex];
|
||||
if (!novel || !page || targetBatch <= batchInformation.batch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all batches from current + 1 up to targetBatch
|
||||
for (
|
||||
let batch = batchInformation.batch + 1;
|
||||
batch <= targetBatch;
|
||||
batch++
|
||||
) {
|
||||
if (batch > batchInformation.total) break;
|
||||
|
||||
let newChapters: ChapterInfo[] = [];
|
||||
try {
|
||||
newChapters =
|
||||
(await getPageChaptersBatched(
|
||||
novel.id,
|
||||
settingsSort,
|
||||
novelSettings.filter,
|
||||
page,
|
||||
batch,
|
||||
)) || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading batch', batch, error);
|
||||
}
|
||||
|
||||
setBatchInformation(prev => ({ ...prev, batch }));
|
||||
extendChapters(newChapters);
|
||||
}
|
||||
},
|
||||
[
|
||||
batchInformation,
|
||||
extendChapters,
|
||||
novel,
|
||||
novelSettings.filter,
|
||||
pageIndex,
|
||||
pages,
|
||||
settingsSort,
|
||||
],
|
||||
);
|
||||
|
||||
// #endregion
|
||||
// #region Mark chapters
|
||||
|
||||
@@ -527,6 +582,9 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (novel) {
|
||||
if (pages.length === 0) {
|
||||
calculatePages(novel).then(setPages);
|
||||
}
|
||||
setLoading(false);
|
||||
} else {
|
||||
getNovel().finally(() => {
|
||||
@@ -538,7 +596,10 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
}, [getNovel, novel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (novel === undefined) return;
|
||||
if (novel === undefined || pages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFetching(true);
|
||||
getChapters()
|
||||
.catch(e => {
|
||||
@@ -562,10 +623,12 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
pages,
|
||||
novel,
|
||||
lastRead,
|
||||
firstUnreadChapter,
|
||||
chapters,
|
||||
novelSettings,
|
||||
batchInformation,
|
||||
getNextChapterBatch,
|
||||
loadUpToBatch,
|
||||
getNovel,
|
||||
setPageIndex,
|
||||
openPage,
|
||||
@@ -593,10 +656,12 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => {
|
||||
pages,
|
||||
novel,
|
||||
lastRead,
|
||||
firstUnreadChapter,
|
||||
chapters,
|
||||
novelSettings,
|
||||
batchInformation,
|
||||
getNextChapterBatch,
|
||||
loadUpToBatch,
|
||||
getNovel,
|
||||
setPageIndex,
|
||||
openPage,
|
||||
|
||||
@@ -167,6 +167,7 @@ export default function usePlugins() {
|
||||
name: _plg.name,
|
||||
version: _plg.version,
|
||||
hasUpdate: false,
|
||||
hasSettings: !!_plg.pluginSettings,
|
||||
};
|
||||
if (newPlugin.id === lastUsedPlugin?.id) {
|
||||
setLastUsedPlugin(newPlugin);
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface ChapterGeneralSettings {
|
||||
swipeGestures: boolean;
|
||||
showScrollPercentage: boolean;
|
||||
useVolumeButtons: boolean;
|
||||
volumeButtonsOffset: number | null;
|
||||
showBatteryAndTime: boolean;
|
||||
autoScroll: boolean;
|
||||
autoScrollInterval: number;
|
||||
@@ -174,6 +175,7 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
|
||||
swipeGestures: false,
|
||||
showScrollPercentage: true,
|
||||
useVolumeButtons: false,
|
||||
volumeButtonsOffset: null,
|
||||
showBatteryAndTime: false,
|
||||
autoScroll: false,
|
||||
autoScrollInterval: 10,
|
||||
|
||||
@@ -56,6 +56,7 @@ export const useTrackedNovel = (novelId: number | 'NO_ID') => {
|
||||
'AniList',
|
||||
'MyAnimeList',
|
||||
'MangaUpdates',
|
||||
'Kitsu',
|
||||
];
|
||||
|
||||
trackerNames.forEach(trackerName => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AuthenticationResult, Tracker, TrackerName } from '@services/Trackers';
|
||||
import { aniListTracker } from '@services/Trackers/aniList';
|
||||
import { myAnimeListTracker } from '@services/Trackers/myAnimeList';
|
||||
import { mangaUpdatesTracker } from '@services/Trackers/mangaUpdates';
|
||||
import { kitsuTracker } from '@services/Trackers/kitsu';
|
||||
import { useMMKVObject, useMMKVString } from 'react-native-mmkv';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ const trackers: Record<TrackerName, Tracker> = {
|
||||
AniList: aniListTracker,
|
||||
MyAnimeList: myAnimeListTracker,
|
||||
MangaUpdates: mangaUpdatesTracker,
|
||||
Kitsu: kitsuTracker,
|
||||
};
|
||||
|
||||
export const getTracker = (name: TrackerName) => {
|
||||
|
||||
@@ -59,6 +59,18 @@ const BottomNavigator = () => {
|
||||
[],
|
||||
);
|
||||
|
||||
const renderTabBar = useCallback(
|
||||
(props: any) => (
|
||||
<BottomTabBar
|
||||
{...props}
|
||||
theme={theme}
|
||||
showLabelsInNav={showLabelsInNav}
|
||||
renderIcon={renderIcon}
|
||||
/>
|
||||
),
|
||||
[theme, showLabelsInNav, renderIcon],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={() => ({
|
||||
@@ -70,14 +82,7 @@ const BottomNavigator = () => {
|
||||
color: theme.onError,
|
||||
},
|
||||
})}
|
||||
tabBar={props => (
|
||||
<BottomTabBar
|
||||
{...props}
|
||||
theme={theme}
|
||||
showLabelsInNav={showLabelsInNav}
|
||||
renderIcon={renderIcon}
|
||||
/>
|
||||
)}
|
||||
tabBar={renderTabBar}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Library"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const defaultCover =
|
||||
'https://github.com/LNReader/lnreader-plugins/blob/master/public/static/coverNotAvailable.webp?raw=true';
|
||||
'https://github.com/lnreader/lnreader-plugins/blob/master/public/static/coverNotAvailable.webp?raw=true';
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { gcm } from '@noble/ciphers/aes.js';
|
||||
import { utf8ToBytes, bytesToUtf8 } from '@noble/ciphers/utils.js';
|
||||
import dayjs from 'dayjs';
|
||||
import { load } from 'cheerio';
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { reverse, uniqBy } from 'lodash-es';
|
||||
import { newer } from '@utils/compareVersion';
|
||||
import { encode, decode } from 'urlencode';
|
||||
|
||||
import { getRepositoriesFromDb } from '@database/queries/RepositoryQueries';
|
||||
import { getUserAgent } from '@hooks/persisted/useUserAgent';
|
||||
import { newer } from '@utils/compareVersion';
|
||||
import NativeFile from '@specs/NativeFile';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { PLUGIN_STORAGE } from '@utils/Storages';
|
||||
|
||||
// packages for plugins
|
||||
import {
|
||||
store,
|
||||
Storage,
|
||||
LocalStorage,
|
||||
SessionStorage,
|
||||
} from './helpers/storage';
|
||||
import { load } from 'cheerio';
|
||||
import dayjs from 'dayjs';
|
||||
import { NovelStatus, Plugin, PluginItem } from './types';
|
||||
import { defaultCover } from './helpers/constants';
|
||||
import { downloadFile, fetchApi, fetchProto, fetchText } from './helpers/fetch';
|
||||
import { FilterTypes } from './types/filterTypes';
|
||||
import { isUrlAbsolute } from './helpers/isAbsoluteUrl';
|
||||
import { downloadFile, fetchApi, fetchProto, fetchText } from './helpers/fetch';
|
||||
import { defaultCover } from './helpers/constants';
|
||||
import { encode, decode } from 'urlencode';
|
||||
import { Parser } from 'htmlparser2';
|
||||
import { getRepositoriesFromDb } from '@database/queries/RepositoryQueries';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { PLUGIN_STORAGE } from '@utils/Storages';
|
||||
import NativeFile from '@specs/NativeFile';
|
||||
import { getUserAgent } from '@hooks/persisted/useUserAgent';
|
||||
|
||||
const packages: Record<string, any> = {
|
||||
'htmlparser2': { Parser },
|
||||
@@ -33,6 +35,8 @@ const packages: Record<string, any> = {
|
||||
'@libs/isAbsoluteUrl': { isUrlAbsolute },
|
||||
'@libs/filterInputs': { FilterTypes },
|
||||
'@libs/defaultCover': { defaultCover },
|
||||
'@libs/aes': { gcm },
|
||||
'@libs/utils': { utf8ToBytes, bytesToUtf8 },
|
||||
};
|
||||
|
||||
const initPlugin = (pluginId: string, rawCode: string) => {
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
import { FilterToValues, Filters } from './filterTypes';
|
||||
|
||||
export interface TextSetting {
|
||||
value: string;
|
||||
label: string;
|
||||
type?: 'Text';
|
||||
}
|
||||
|
||||
export interface SwitchSetting {
|
||||
value: boolean;
|
||||
label: string;
|
||||
type: 'Switch';
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SelectSetting {
|
||||
value: string;
|
||||
label: string;
|
||||
type: 'Select';
|
||||
options: SelectOption[];
|
||||
}
|
||||
|
||||
export interface CheckboxOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface CheckboxGroupSetting {
|
||||
value: string[];
|
||||
label: string;
|
||||
type: 'CheckboxGroup';
|
||||
options: CheckboxOption[];
|
||||
}
|
||||
|
||||
export type PluginSetting =
|
||||
| TextSetting
|
||||
| SwitchSetting
|
||||
| SelectSetting
|
||||
| CheckboxGroupSetting;
|
||||
|
||||
export interface PluginSettings {
|
||||
[key: string]: PluginSetting;
|
||||
}
|
||||
|
||||
export interface NovelItem {
|
||||
id: undefined;
|
||||
name: string;
|
||||
@@ -69,7 +115,7 @@ export interface ImageRequestInit {
|
||||
export interface Plugin extends PluginItem {
|
||||
imageRequestInit: ImageRequestInit;
|
||||
filters?: Filters;
|
||||
pluginSettings: any;
|
||||
pluginSettings?: PluginSettings;
|
||||
popularNovels: (
|
||||
pageNo: number,
|
||||
options?: PopularNovelsOptions<Filters>,
|
||||
|
||||
@@ -143,7 +143,7 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
|
||||
|
||||
setInActivity(prev => ({ ...prev, [item.path]: false }));
|
||||
}}
|
||||
selectedNovelIds={[]}
|
||||
hasSelection={false}
|
||||
imageRequestInit={imageRequestInit}
|
||||
/>
|
||||
);
|
||||
@@ -169,7 +169,7 @@ const BrowseSourceScreen = ({ route, navigation }: BrowseSourceScreenProps) => {
|
||||
{
|
||||
backgroundColor: theme.primary,
|
||||
marginBottom: bottom + 16,
|
||||
marginRight: right + 16,
|
||||
marginEnd: right + 16,
|
||||
},
|
||||
]}
|
||||
label={getString('common.filter')}
|
||||
@@ -196,6 +196,6 @@ const styles = StyleSheet.create({
|
||||
bottom: 0,
|
||||
margin: 16,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
end: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
FilterToValues,
|
||||
Filters,
|
||||
} from '@plugins/types/filterTypes';
|
||||
import { Button } from '@components/index';
|
||||
import { Button, Menu } from '@components/index';
|
||||
import { Checkbox } from '@components/Checkbox/Checkbox';
|
||||
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
|
||||
import { useBoolean } from '@hooks';
|
||||
import { Menu, TextInput, overlay } from 'react-native-paper';
|
||||
import { TextInput, overlay } from 'react-native-paper';
|
||||
import { getValueFor } from './filterUtils';
|
||||
import { getString } from '@strings/translations';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
@@ -100,7 +100,7 @@ const FilterItem: React.FC<FilterItemProps> = ({
|
||||
return (
|
||||
<View style={styles.pickerContainer}>
|
||||
<Menu
|
||||
style={styles.flex}
|
||||
fullWidth
|
||||
visible={isVisible}
|
||||
contentStyle={{ backgroundColor: theme.surfaceVariant }}
|
||||
anchor={
|
||||
@@ -350,45 +350,53 @@ const FilterBottomSheet: React.FC<BottomSheetProps> = ({
|
||||
bottomSheetRef={filterSheetRef}
|
||||
snapPoints={[400, 600]}
|
||||
bottomInset={bottom}
|
||||
backgroundStyle={styles.transparent}
|
||||
style={[styles.container, { backgroundColor: overlay(2, theme.surface) }]}
|
||||
handleComponent={() => (
|
||||
<View
|
||||
style={[styles.buttonContainer, { borderBottomColor: theme.outline }]}
|
||||
>
|
||||
<Button
|
||||
title={getString('common.reset')}
|
||||
onPress={() => {
|
||||
setSelectedFilters(filters);
|
||||
clearFilters(filters);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title={getString('common.filter')}
|
||||
textColor={theme.onPrimary}
|
||||
onPress={() => {
|
||||
setFilters(selectedFilters);
|
||||
filterSheetRef?.current?.close();
|
||||
}}
|
||||
mode="contained"
|
||||
handleComponent={null}
|
||||
children={
|
||||
<View style={styles.flex}>
|
||||
<View
|
||||
style={[
|
||||
styles.buttonContainer,
|
||||
{ borderBottomColor: theme.outline },
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
title={getString('common.reset')}
|
||||
onPress={() => {
|
||||
setSelectedFilters(filters);
|
||||
clearFilters(filters);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title={getString('common.filter')}
|
||||
textColor={theme.onPrimary}
|
||||
onPress={() => {
|
||||
setFilters(selectedFilters);
|
||||
filterSheetRef?.current?.close();
|
||||
}}
|
||||
mode="contained"
|
||||
/>
|
||||
</View>
|
||||
<BottomSheetFlatList
|
||||
data={
|
||||
filters &&
|
||||
(Object.entries(filters) as [string, Filters[string]][])
|
||||
}
|
||||
keyExtractor={(item: [string, Filters[string]]) =>
|
||||
'filter' + item[0]
|
||||
}
|
||||
renderItem={({ item }: { item: [string, Filters[string]] }) => (
|
||||
<FilterItem
|
||||
theme={theme}
|
||||
filter={item[1]}
|
||||
filterKey={item[0]}
|
||||
selectedFilters={selectedFilters}
|
||||
setSelectedFilters={setSelectedFilters}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
>
|
||||
<BottomSheetFlatList
|
||||
data={filters && Object.entries(filters)}
|
||||
keyExtractor={item => 'filter' + item[0]}
|
||||
renderItem={({ item }) => (
|
||||
<FilterItem
|
||||
theme={theme}
|
||||
filter={item[1]}
|
||||
filterKey={item[0]}
|
||||
selectedFilters={selectedFilters}
|
||||
setSelectedFilters={setSelectedFilters}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</BottomSheet>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -44,9 +44,8 @@ const CategoryCard: React.FC<CategoryCardProps> = ({
|
||||
styles.cardCtn,
|
||||
{
|
||||
backgroundColor: theme.secondaryContainer,
|
||||
opacity: isActive ? 0.8 : 1,
|
||||
elevation: isActive ? 8 : 2,
|
||||
},
|
||||
isActive && styles.activeCard,
|
||||
]}
|
||||
>
|
||||
<View style={styles.buttonsCtn}>
|
||||
@@ -77,17 +76,19 @@ const CategoryCard: React.FC<CategoryCardProps> = ({
|
||||
</Text>
|
||||
{category.id === 2 && (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: theme.primary,
|
||||
paddingHorizontal: 8,
|
||||
}}
|
||||
style={[
|
||||
styles.badge,
|
||||
{
|
||||
backgroundColor: theme.tertiaryContainer,
|
||||
},
|
||||
]}
|
||||
>
|
||||
System
|
||||
</Badge>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.flex} />
|
||||
<View style={{ opacity: category.id === 2 ? 0.4 : 1 }}>
|
||||
<View style={[category.id === 2 && styles.disabledOpacity]}>
|
||||
<IconButton
|
||||
name="pencil-outline"
|
||||
color={category.id === 2 ? theme.outline : theme.onSurface}
|
||||
@@ -97,7 +98,7 @@ const CategoryCard: React.FC<CategoryCardProps> = ({
|
||||
disabled={category.id === 2}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ opacity: category.id === 2 ? 0.4 : 1 }}>
|
||||
<View style={[category.id === 2 && styles.disabledOpacity]}>
|
||||
<IconButton
|
||||
name="delete-outline"
|
||||
color={category.id === 2 ? theme.outline : theme.onSurface}
|
||||
@@ -143,24 +144,34 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 8,
|
||||
},
|
||||
dragHandle: {
|
||||
marginRight: 4,
|
||||
marginEnd: 4,
|
||||
},
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
manageBtn: {
|
||||
marginLeft: 16,
|
||||
marginStart: 16,
|
||||
},
|
||||
name: {
|
||||
marginLeft: 16,
|
||||
marginRight: 8,
|
||||
marginStart: 16,
|
||||
marginEnd: 8,
|
||||
},
|
||||
nameCtn: {
|
||||
alignItems: 'center',
|
||||
flexGrow: 1,
|
||||
flexDirection: 'row',
|
||||
marginLeft: 8,
|
||||
paddingRight: 16,
|
||||
marginStart: 8,
|
||||
paddingEnd: 16,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
activeCard: {
|
||||
opacity: 0.8,
|
||||
elevation: 8,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
disabledOpacity: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ const GlobalSearchSourceResults: React.FC<{ item: GlobalSearchResult }> = ({
|
||||
[novelItem.path]: false,
|
||||
}));
|
||||
}}
|
||||
selectedNovelIds={[]}
|
||||
hasSelection={false}
|
||||
isSelected={false}
|
||||
imageRequestInit={imageRequestInit}
|
||||
/>
|
||||
@@ -175,6 +175,7 @@ const GlobalSearchSourceResults: React.FC<{ item: GlobalSearchResult }> = ({
|
||||
item.plugin.site,
|
||||
navigateToNovel,
|
||||
navigation,
|
||||
imageRequestInit,
|
||||
noResultsColor,
|
||||
novelInLibrary,
|
||||
switchNovelToLibrary,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* eslint-disable react-native/no-inline-styles */
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
import { TabView, TabBar } from 'react-native-tab-view';
|
||||
import Color from 'color';
|
||||
|
||||
import { useSearch } from '@hooks';
|
||||
import { usePlugins, useTheme } from '@hooks/persisted';
|
||||
@@ -20,6 +22,7 @@ const BrowseScreen = ({ navigation }: BrowseScreenProps) => {
|
||||
const theme = useTheme();
|
||||
const { searchText, setSearchText, clearSearchbar } = useSearch();
|
||||
const { languagesFilter } = usePlugins();
|
||||
const layout = useWindowDimensions();
|
||||
|
||||
const searchbarActions = useMemo(
|
||||
() =>
|
||||
@@ -66,6 +69,7 @@ const BrowseScreen = ({ navigation }: BrowseScreenProps) => {
|
||||
/>
|
||||
<TabView
|
||||
navigationState={{ index, routes }}
|
||||
initialLayout={{ width: layout.width }}
|
||||
renderScene={({ route }) => {
|
||||
if (languagesFilter.length === 0) {
|
||||
return (
|
||||
@@ -96,6 +100,11 @@ const BrowseScreen = ({ navigation }: BrowseScreenProps) => {
|
||||
indicatorStyle={{ backgroundColor: theme.primary, height: 3 }}
|
||||
style={{
|
||||
backgroundColor: theme.surface,
|
||||
elevation: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: Color(theme.isDark ? '#FFFFFF' : '#000000')
|
||||
.alpha(0.12)
|
||||
.string(),
|
||||
}}
|
||||
inactiveColor={theme.secondary}
|
||||
activeColor={theme.primary}
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { TextInput } from 'react-native-paper';
|
||||
import { Button, Modal, SwitchItem } from '@components/index';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { TextInput, overlay } from 'react-native-paper';
|
||||
import { Button, Modal, SwitchItem, Checkbox, Menu } from '@components/index';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { getString } from '@strings/translations';
|
||||
import { Storage } from '@plugins/helpers/storage';
|
||||
|
||||
interface PluginSetting {
|
||||
value: string;
|
||||
label: string;
|
||||
type?: 'Switch';
|
||||
}
|
||||
|
||||
interface PluginSettings {
|
||||
[key: string]: PluginSetting;
|
||||
}
|
||||
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
|
||||
import { PluginSettings } from '@plugins/types';
|
||||
|
||||
interface SourceSettingsModal {
|
||||
visible: boolean;
|
||||
@@ -36,8 +28,9 @@ const SourceSettingsModal: React.FC<SourceSettingsModal> = ({
|
||||
const theme = useTheme();
|
||||
|
||||
const [formValues, setFormValues] = useState<
|
||||
Record<string, string | boolean>
|
||||
Record<string, string | boolean | string[]>
|
||||
>({});
|
||||
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (pluginSettings) {
|
||||
@@ -66,13 +59,18 @@ const SourceSettingsModal: React.FC<SourceSettingsModal> = ({
|
||||
}
|
||||
}, [pluginSettings, pluginId]);
|
||||
|
||||
const handleChange = (key: string, value: string | boolean) => {
|
||||
const handleChange = (key: string, value: string | boolean | string[]) => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const insertOrRemoveIntoArray = (array: string[], val: string): string[] =>
|
||||
array.indexOf(val) > -1
|
||||
? array.filter(ele => ele !== val)
|
||||
: [...array, val];
|
||||
|
||||
const handleSave = () => {
|
||||
const storage = new Storage(pluginId);
|
||||
Object.entries(formValues).forEach(([key, value]) => {
|
||||
@@ -112,6 +110,104 @@ const SourceSettingsModal: React.FC<SourceSettingsModal> = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (setting?.type === 'Select') {
|
||||
const selectedOption = setting.options.find(
|
||||
opt => opt.value === formValues[key],
|
||||
);
|
||||
const isMenuOpen = !!openMenus[key];
|
||||
return (
|
||||
<View key={key} style={styles.selectContainer}>
|
||||
<Menu
|
||||
fullWidth
|
||||
visible={isMenuOpen}
|
||||
contentStyle={{ backgroundColor: theme.surfaceVariant }}
|
||||
anchor={
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
setOpenMenus(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
mode="outlined"
|
||||
label={
|
||||
<Text
|
||||
style={{
|
||||
color: isMenuOpen ? theme.primary : theme.onSurface,
|
||||
backgroundColor: overlay(2, theme.surface),
|
||||
}}
|
||||
>
|
||||
{` ${setting.label} `}
|
||||
</Text>
|
||||
}
|
||||
value={selectedOption?.label || ''}
|
||||
editable={false}
|
||||
theme={{ colors: { background: 'transparent' } }}
|
||||
outlineColor={
|
||||
isMenuOpen ? theme.primary : theme.onSurface
|
||||
}
|
||||
textColor={isMenuOpen ? theme.primary : theme.onSurface}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</Pressable>
|
||||
}
|
||||
onDismiss={() =>
|
||||
setOpenMenus(prev => ({ ...prev, [key]: false }))
|
||||
}
|
||||
>
|
||||
{setting.options.map(option => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
title={option.label}
|
||||
titleStyle={{ color: theme.onSurfaceVariant }}
|
||||
onPress={() => {
|
||||
handleChange(key, option.value);
|
||||
setOpenMenus(prev => ({ ...prev, [key]: false }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (setting?.type === 'CheckboxGroup') {
|
||||
const value = (formValues[key] || []) as string[];
|
||||
const isExpanded = !!openMenus[key];
|
||||
return (
|
||||
<View key={key}>
|
||||
<Pressable
|
||||
style={styles.checkboxHeader}
|
||||
onPress={() =>
|
||||
setOpenMenus(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
>
|
||||
<Text style={{ color: theme.onSurfaceVariant }}>
|
||||
{setting.label}
|
||||
</Text>
|
||||
<MaterialCommunityIcons
|
||||
name={isExpanded ? 'chevron-up' : 'chevron-down'}
|
||||
color={theme.onSurface}
|
||||
size={24}
|
||||
/>
|
||||
</Pressable>
|
||||
{isExpanded &&
|
||||
setting.options.map(option => (
|
||||
<Checkbox
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
theme={theme}
|
||||
status={value.includes(option.value)}
|
||||
onPress={() =>
|
||||
handleChange(
|
||||
key,
|
||||
insertOrRemoveIntoArray(value, option.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TextInput
|
||||
key={key}
|
||||
@@ -161,4 +257,14 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
selectContainer: {
|
||||
marginVertical: 8,
|
||||
},
|
||||
checkboxHeader: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ export const PluginListItem = memo(
|
||||
|
||||
const rightActionStyle = useMemo(
|
||||
() => [styles.buttonGroup, { backgroundColor: theme.primary }],
|
||||
[theme.error],
|
||||
[theme.primary],
|
||||
);
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, { backgroundColor: theme.surface }],
|
||||
@@ -155,6 +155,7 @@ export const PluginListItem = memo(
|
||||
theme,
|
||||
handlePinPress,
|
||||
handleDeletePress,
|
||||
handleWebviewPress,
|
||||
isPluginPinned,
|
||||
],
|
||||
);
|
||||
@@ -242,7 +243,7 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 12,
|
||||
},
|
||||
details: {
|
||||
marginLeft: 16,
|
||||
marginStart: 16,
|
||||
},
|
||||
flex: { flex: 1 },
|
||||
icon: {
|
||||
@@ -254,7 +255,7 @@ const styles = StyleSheet.create({
|
||||
lineHeight: 20,
|
||||
},
|
||||
pinnedIndicator: {
|
||||
marginRight: -8,
|
||||
marginEnd: -8,
|
||||
},
|
||||
rightActionsContainer: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -129,7 +129,7 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 12,
|
||||
},
|
||||
details: {
|
||||
marginLeft: 16,
|
||||
marginStart: 16,
|
||||
},
|
||||
flex: { flex: 1 },
|
||||
icon: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
@@ -97,6 +97,25 @@ const BrowseMalScreen = ({ navigation }: BrowseMalScreenProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const loadingMore = useRef(false);
|
||||
|
||||
const onScroll = useCallback(
|
||||
({ nativeEvent }: { nativeEvent: NativeScrollEvent }) => {
|
||||
if (!searchText && !loadingMore.current && isCloseToBottom(nativeEvent)) {
|
||||
loadingMore.current = true;
|
||||
|
||||
setLimit(before => {
|
||||
const newLimit = before + 50;
|
||||
getNovels(newLimit).finally(() => {
|
||||
loadingMore.current = false;
|
||||
});
|
||||
return newLimit;
|
||||
});
|
||||
}
|
||||
},
|
||||
[searchText, getNovels],
|
||||
);
|
||||
|
||||
const ListEmptyComponent = useCallback(
|
||||
() => (
|
||||
<ErrorView
|
||||
@@ -145,12 +164,7 @@ const BrowseMalScreen = ({ navigation }: BrowseMalScreenProps) => {
|
||||
keyExtractor={(item, index) => item.novelName + index}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
onScroll={({ nativeEvent }) => {
|
||||
if (!searchText && isCloseToBottom(nativeEvent)) {
|
||||
getNovels(limit + 50);
|
||||
setLimit(before => before + 50);
|
||||
}
|
||||
}}
|
||||
onScroll={onScroll}
|
||||
ListFooterComponent={
|
||||
!searchText ? (
|
||||
<View style={styles.paddingVertical}>
|
||||
|
||||
@@ -69,7 +69,7 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginLeft: 16,
|
||||
marginStart: 16,
|
||||
},
|
||||
sourceIcon: {
|
||||
borderRadius: 4,
|
||||
|
||||
@@ -72,13 +72,11 @@ const HistoryCard: React.FC<HistoryCardProps> = ({
|
||||
{history.novelName}
|
||||
</Text>
|
||||
<Text style={{ color: theme.onSurfaceVariant }}>
|
||||
{`${getString('historyScreen.chapter')} ${
|
||||
history.chapterNumber
|
||||
} • ${dayjs(history.readTime).format('LT').toUpperCase()}` +
|
||||
`${
|
||||
history.progress && history.progress > 0
|
||||
? ' • ' + history.progress + '%'
|
||||
: ''
|
||||
{`${getString('historyScreen.chapter')} ${history.chapterNumber
|
||||
} • ${dayjs(history.readTime).format('LT').toUpperCase()}` +
|
||||
`${history.progress && history.progress > 0
|
||||
? ' • ' + history.progress + '%'
|
||||
: ''
|
||||
}`}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -118,7 +116,7 @@ const styles = StyleSheet.create({
|
||||
detailsContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
marginLeft: 16,
|
||||
marginStart: 16,
|
||||
},
|
||||
imageAndNameContainer: {
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -48,6 +48,8 @@ import ServiceManager from '@services/ServiceManager';
|
||||
import useImport from '@hooks/persisted/useImport';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { useLibraryContext } from '@components/Context/LibraryContext';
|
||||
import { xor } from 'lodash-es';
|
||||
import { SelectionContext } from './SelectionContext';
|
||||
|
||||
type State = NavigationState<{
|
||||
key: string;
|
||||
@@ -73,7 +75,7 @@ type TabViewLabelProps = {
|
||||
const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
const { searchText, setSearchText, clearSearchbar } = useSearch();
|
||||
const theme = useTheme();
|
||||
const styles = createStyles(theme);
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
const { left: leftInset, right: rightInset } = useSafeAreaInsets();
|
||||
const {
|
||||
library,
|
||||
@@ -100,17 +102,33 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
setFalse: closeSetCategoryModal,
|
||||
} = useBoolean();
|
||||
|
||||
const handleClearSearchbar = () => {
|
||||
clearSearchbar();
|
||||
};
|
||||
|
||||
const [selectedNovelIds, setSelectedNovelIds] = useState<number[]>([]);
|
||||
|
||||
const selectedIdsSet = useMemo(
|
||||
() => new Set(selectedNovelIds),
|
||||
[selectedNovelIds],
|
||||
);
|
||||
const hasSelection = selectedNovelIds.length > 0;
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(id: number) => setSelectedNovelIds(prev => xor(prev, [id])),
|
||||
[],
|
||||
);
|
||||
|
||||
const selectionContextValue = useMemo(
|
||||
() => ({
|
||||
selectedIdsSet,
|
||||
hasSelection,
|
||||
toggleSelection,
|
||||
setSelectedNovelIds,
|
||||
}),
|
||||
[selectedIdsSet, hasSelection, toggleSelection],
|
||||
);
|
||||
|
||||
const currentNovels = useMemo(() => {
|
||||
if (!categories.length) return [];
|
||||
|
||||
const ids = categories[index].novelIds;
|
||||
return library.filter(l => ids.includes(l.id));
|
||||
const idsSet = new Set(categories[index].novelIds);
|
||||
return library.filter(l => idsSet.has(l.id));
|
||||
}, [categories, index, library]);
|
||||
|
||||
useBackHandler(() => {
|
||||
@@ -139,25 +157,35 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
? getString('libraryScreen.searchbar')
|
||||
: `${selectedNovelIds.length} selected`;
|
||||
|
||||
function openRandom() {
|
||||
const novels = currentNovels;
|
||||
const randomNovel = novels[Math.floor(Math.random() * novels.length)];
|
||||
const openRandom = useCallback(() => {
|
||||
const randomNovel =
|
||||
currentNovels[Math.floor(Math.random() * currentNovels.length)];
|
||||
if (randomNovel) {
|
||||
navigation.navigate('ReaderStack', {
|
||||
screen: 'Novel',
|
||||
params: randomNovel,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [currentNovels, navigation]);
|
||||
|
||||
const pickAndImport = useCallback(() => {
|
||||
DocumentPicker.getDocumentAsync({
|
||||
type: 'application/epub+zip',
|
||||
copyToCacheDirectory: true,
|
||||
copyToCacheDirectory: false,
|
||||
multiple: true,
|
||||
}).then(importNovel);
|
||||
}, [importNovel]);
|
||||
|
||||
const searchLower = useMemo(() => searchText.toLowerCase(), [searchText]);
|
||||
|
||||
const tabBarBorderColor = useMemo(
|
||||
() =>
|
||||
Color(theme.isDark ? '#FFFFFF' : '#000000')
|
||||
.alpha(0.12)
|
||||
.string(),
|
||||
[theme.isDark],
|
||||
);
|
||||
|
||||
const renderTabBar = useCallback(
|
||||
(props: SceneRendererProps & { navigationState: State }) => {
|
||||
return categories.length ? (
|
||||
@@ -168,9 +196,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
style={[
|
||||
{
|
||||
backgroundColor: theme.surface,
|
||||
borderBottomColor: Color(theme.isDark ? '#FFFFFF' : '#000000')
|
||||
.alpha(0.12)
|
||||
.string(),
|
||||
borderBottomColor: tabBarBorderColor,
|
||||
},
|
||||
styles.tabBar,
|
||||
]}
|
||||
@@ -187,7 +213,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
styles.tabBar,
|
||||
styles.tabBarIndicator,
|
||||
styles.tabStyle,
|
||||
theme.isDark,
|
||||
tabBarBorderColor,
|
||||
theme.primary,
|
||||
theme.rippleColor,
|
||||
theme.secondary,
|
||||
@@ -207,14 +233,16 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
title: string;
|
||||
};
|
||||
}) => {
|
||||
const ids = route.novelIds;
|
||||
const unfilteredNovels = library.filter(l => ids.includes(l.id));
|
||||
const idsSet = new Set(route.novelIds);
|
||||
const unfilteredNovels = library.filter(l => idsSet.has(l.id));
|
||||
|
||||
const novels = unfilteredNovels.filter(
|
||||
n =>
|
||||
n.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(n.author?.toLowerCase().includes(searchText.toLowerCase()) ?? false),
|
||||
);
|
||||
const novels = searchLower
|
||||
? unfilteredNovels.filter(
|
||||
n =>
|
||||
n.name.toLowerCase().includes(searchLower) ||
|
||||
(n.author?.toLowerCase().includes(searchLower) ?? false),
|
||||
)
|
||||
: unfilteredNovels;
|
||||
|
||||
return isLoading ? (
|
||||
<SourceScreenSkeletonLoading theme={theme} />
|
||||
@@ -237,8 +265,6 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
categoryId={route.id}
|
||||
categoryName={route.name}
|
||||
novels={novels}
|
||||
selectedNovelIds={selectedNovelIds}
|
||||
setSelectedNovelIds={setSelectedNovelIds}
|
||||
pickAndImport={pickAndImport}
|
||||
navigation={navigation}
|
||||
/>
|
||||
@@ -251,7 +277,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
navigation,
|
||||
pickAndImport,
|
||||
searchText,
|
||||
selectedNovelIds,
|
||||
searchLower,
|
||||
styles.globalSearchBtn,
|
||||
theme,
|
||||
],
|
||||
@@ -259,6 +285,8 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
|
||||
const renderLabel = useCallback(
|
||||
({ route, color }: TabViewLabelProps) => {
|
||||
const novelIds = route?.novelIds?.filter(id => id !== 0);
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Text style={[{ color }, styles.fontWeight500]}>{route.title}</Text>
|
||||
@@ -272,7 +300,7 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
<Text
|
||||
style={[styles.badgetText, { color: theme.onSurfaceVariant }]}
|
||||
>
|
||||
{route?.novelIds.length}
|
||||
{novelIds.length}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -289,6 +317,123 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
],
|
||||
);
|
||||
|
||||
const handleLeftIconPress = useCallback(() => {
|
||||
if (selectedNovelIds.length > 0) {
|
||||
setSelectedNovelIds([]);
|
||||
}
|
||||
}, [selectedNovelIds.length]);
|
||||
|
||||
const rightIcons = useMemo(
|
||||
() =>
|
||||
selectedNovelIds.length
|
||||
? [
|
||||
{
|
||||
iconName: 'select-all' as const,
|
||||
onPress: () =>
|
||||
setSelectedNovelIds(currentNovels.map(novel => novel.id)),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
iconName: 'filter-variant' as const,
|
||||
onPress: () => bottomSheetRef.current?.present(),
|
||||
},
|
||||
],
|
||||
[selectedNovelIds.length, currentNovels],
|
||||
);
|
||||
|
||||
const menuButtons = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: getString('libraryScreen.extraMenu.updateLibrary'),
|
||||
onPress: () =>
|
||||
ServiceManager.manager.addTask({ name: 'UPDATE_LIBRARY' }),
|
||||
},
|
||||
{
|
||||
title: getString('libraryScreen.extraMenu.updateCategory'),
|
||||
onPress: () =>
|
||||
categories[index]?.id !== 2 &&
|
||||
ServiceManager.manager.addTask({
|
||||
name: 'UPDATE_LIBRARY',
|
||||
data: {
|
||||
categoryId: categories[index].id,
|
||||
categoryName: categories[index].name,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: getString('libraryScreen.extraMenu.importEpub'),
|
||||
onPress: pickAndImport,
|
||||
},
|
||||
{
|
||||
title: getString('libraryScreen.extraMenu.openRandom'),
|
||||
onPress: openRandom,
|
||||
},
|
||||
],
|
||||
[categories, index, pickAndImport, openRandom],
|
||||
);
|
||||
|
||||
const handleFABPress = useCallback(() => {
|
||||
if (history?.[0]) {
|
||||
navigation.navigate('ReaderStack', {
|
||||
screen: 'Chapter',
|
||||
params: {
|
||||
novel: {
|
||||
path: history[0].novelPath,
|
||||
pluginId: history[0].pluginId,
|
||||
name: history[0].novelName,
|
||||
} as NovelInfo,
|
||||
chapter: history[0],
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [history, navigation]);
|
||||
|
||||
const handleEditCategories = useCallback(() => setSelectedNovelIds([]), []);
|
||||
|
||||
const handleCategorySuccess = useCallback(() => {
|
||||
setSelectedNovelIds([]);
|
||||
refetchLibrary();
|
||||
}, [refetchLibrary]);
|
||||
|
||||
const bottomSheetStyle = useMemo(
|
||||
() => ({ marginStart: leftInset, marginEnd: rightInset }),
|
||||
[leftInset, rightInset],
|
||||
);
|
||||
|
||||
const actionbarViewStyle = useMemo(
|
||||
() => ({ paddingStart: leftInset, paddingEnd: rightInset }),
|
||||
[leftInset, rightInset],
|
||||
);
|
||||
|
||||
const markAllRead = useCallback(async () => {
|
||||
await Promise.all(selectedNovelIds.map(id => markAllChaptersRead(id)));
|
||||
setSelectedNovelIds([]);
|
||||
refetchLibrary();
|
||||
}, [selectedNovelIds, refetchLibrary]);
|
||||
|
||||
const markAllUnread = useCallback(async () => {
|
||||
await Promise.all(selectedNovelIds.map(id => markAllChaptersUnread(id)));
|
||||
setSelectedNovelIds([]);
|
||||
refetchLibrary();
|
||||
}, [selectedNovelIds, refetchLibrary]);
|
||||
|
||||
const deleteSelected = useCallback(async () => {
|
||||
await removeNovelsFromLibrary(selectedNovelIds);
|
||||
setSelectedNovelIds([]);
|
||||
refetchLibrary();
|
||||
}, [selectedNovelIds, refetchLibrary]);
|
||||
|
||||
const actionbarActions = useMemo(
|
||||
() => [
|
||||
{ icon: 'label-outline' as const, onPress: showSetCategoryModal },
|
||||
{ icon: 'check' as const, onPress: markAllRead },
|
||||
{ icon: 'check-outline' as const, onPress: markAllUnread },
|
||||
{ icon: 'delete-outline' as const, onPress: deleteSelected },
|
||||
],
|
||||
[showSetCategoryModal, markAllRead, markAllUnread, deleteSelected],
|
||||
);
|
||||
|
||||
const navigationState = useMemo(
|
||||
() => ({
|
||||
index,
|
||||
@@ -305,61 +450,13 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
<SafeAreaView excludeBottom>
|
||||
<SearchbarV2
|
||||
searchText={searchText}
|
||||
clearSearchbar={handleClearSearchbar}
|
||||
clearSearchbar={clearSearchbar}
|
||||
placeholder={searchbarPlaceholder}
|
||||
onLeftIconPress={() => {
|
||||
if (selectedNovelIds.length > 0) {
|
||||
setSelectedNovelIds([]);
|
||||
}
|
||||
}}
|
||||
onLeftIconPress={handleLeftIconPress}
|
||||
onChangeText={setSearchText}
|
||||
leftIcon={selectedNovelIds.length ? 'close' : 'magnify'}
|
||||
rightIcons={
|
||||
selectedNovelIds.length
|
||||
? [
|
||||
{
|
||||
iconName: 'select-all',
|
||||
onPress: () =>
|
||||
setSelectedNovelIds(currentNovels.map(novel => novel.id)),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
iconName: 'filter-variant',
|
||||
onPress: () => bottomSheetRef.current?.present(),
|
||||
},
|
||||
]
|
||||
}
|
||||
menuButtons={[
|
||||
{
|
||||
title: getString('libraryScreen.extraMenu.updateLibrary'),
|
||||
onPress: () =>
|
||||
ServiceManager.manager.addTask({
|
||||
name: 'UPDATE_LIBRARY',
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: getString('libraryScreen.extraMenu.updateCategory'),
|
||||
onPress: () =>
|
||||
//2 = local category
|
||||
library[index].id !== 2 &&
|
||||
ServiceManager.manager.addTask({
|
||||
name: 'UPDATE_LIBRARY',
|
||||
data: {
|
||||
categoryId: library[index].id,
|
||||
categoryName: library[index].name,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: getString('libraryScreen.extraMenu.importEpub'),
|
||||
onPress: pickAndImport,
|
||||
},
|
||||
{
|
||||
title: getString('libraryScreen.extraMenu.openRandom'),
|
||||
onPress: openRandom,
|
||||
},
|
||||
]}
|
||||
rightIcons={rightIcons}
|
||||
menuButtons={menuButtons}
|
||||
theme={theme}
|
||||
/>
|
||||
{downloadedOnlyMode ? (
|
||||
@@ -379,17 +476,19 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TabView
|
||||
commonOptions={{
|
||||
label: renderLabel,
|
||||
}}
|
||||
lazy
|
||||
navigationState={navigationState}
|
||||
renderTabBar={renderTabBar}
|
||||
renderScene={renderScene}
|
||||
onIndexChange={setIndex}
|
||||
initialLayout={{ width: layout.width }}
|
||||
/>
|
||||
<SelectionContext.Provider value={selectionContextValue}>
|
||||
<TabView
|
||||
commonOptions={{
|
||||
label: renderLabel,
|
||||
}}
|
||||
lazy
|
||||
navigationState={navigationState}
|
||||
renderTabBar={renderTabBar}
|
||||
renderScene={renderScene}
|
||||
onIndexChange={setIndex}
|
||||
initialLayout={{ width: layout.width }}
|
||||
/>
|
||||
</SelectionContext.Provider>
|
||||
|
||||
{useLibraryFAB &&
|
||||
!isHistoryLoading &&
|
||||
@@ -399,83 +498,31 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
|
||||
<FAB
|
||||
style={[
|
||||
styles.fab,
|
||||
{ backgroundColor: theme.primary, marginRight: rightInset + 16 },
|
||||
{ backgroundColor: theme.primary, marginEnd: rightInset + 16 },
|
||||
]}
|
||||
color={theme.onPrimary}
|
||||
uppercase={false}
|
||||
label={getString('common.resume')}
|
||||
icon="play"
|
||||
onPress={() => {
|
||||
navigation.navigate('ReaderStack', {
|
||||
screen: 'Chapter',
|
||||
params: {
|
||||
novel: {
|
||||
path: history[0].novelPath,
|
||||
pluginId: history[0].pluginId,
|
||||
name: history[0].novelName,
|
||||
} as NovelInfo,
|
||||
chapter: history[0],
|
||||
},
|
||||
});
|
||||
}}
|
||||
onPress={handleFABPress}
|
||||
/>
|
||||
) : null}
|
||||
<SetCategoryModal
|
||||
novelIds={selectedNovelIds}
|
||||
closeModal={closeSetCategoryModal}
|
||||
onEditCategories={() => setSelectedNovelIds([])}
|
||||
onEditCategories={handleEditCategories}
|
||||
visible={setCategoryModalVisible}
|
||||
onSuccess={() => {
|
||||
setSelectedNovelIds([]);
|
||||
refetchLibrary();
|
||||
}}
|
||||
onSuccess={handleCategorySuccess}
|
||||
/>
|
||||
<LibraryBottomSheet
|
||||
bottomSheetRef={bottomSheetRef}
|
||||
style={{ marginLeft: leftInset, marginRight: rightInset }}
|
||||
style={bottomSheetStyle}
|
||||
/>
|
||||
<Portal>
|
||||
<Actionbar
|
||||
viewStyle={{ paddingLeft: leftInset, paddingRight: rightInset }}
|
||||
active={selectedNovelIds.length > 0}
|
||||
actions={[
|
||||
{
|
||||
icon: 'label-outline',
|
||||
onPress: showSetCategoryModal,
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
onPress: async () => {
|
||||
const promises: Promise<any>[] = [];
|
||||
selectedNovelIds.map(id =>
|
||||
promises.push(markAllChaptersRead(id)),
|
||||
);
|
||||
await Promise.all(promises);
|
||||
setSelectedNovelIds([]);
|
||||
refetchLibrary();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'check-outline',
|
||||
onPress: async () => {
|
||||
const promises: Promise<any>[] = [];
|
||||
selectedNovelIds.map(id =>
|
||||
promises.push(markAllChaptersUnread(id)),
|
||||
);
|
||||
await Promise.all(promises);
|
||||
setSelectedNovelIds([]);
|
||||
refetchLibrary();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'delete-outline',
|
||||
onPress: () => {
|
||||
removeNovelsFromLibrary(selectedNovelIds);
|
||||
setSelectedNovelIds([]);
|
||||
refetchLibrary();
|
||||
},
|
||||
},
|
||||
]}
|
||||
viewStyle={actionbarViewStyle}
|
||||
active={hasSelection}
|
||||
actions={actionbarActions}
|
||||
/>
|
||||
</Portal>
|
||||
</SafeAreaView>
|
||||
@@ -488,7 +535,7 @@ function createStyles(theme: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
badgeCtn: {
|
||||
borderRadius: 50,
|
||||
marginLeft: 2,
|
||||
marginStart: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
position: 'relative',
|
||||
@@ -500,7 +547,7 @@ function createStyles(theme: ThemeColors) {
|
||||
bottom: 0,
|
||||
margin: 16,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
end: 0,
|
||||
},
|
||||
fontWeight500: {
|
||||
fontWeight: 500,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
createContext,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useContext,
|
||||
} from 'react';
|
||||
|
||||
interface SelectionContextValue {
|
||||
selectedIdsSet: Set<number>;
|
||||
hasSelection: boolean;
|
||||
toggleSelection: (id: number) => void;
|
||||
setSelectedNovelIds: Dispatch<SetStateAction<number[]>>;
|
||||
}
|
||||
|
||||
const defaultValue: SelectionContextValue = {
|
||||
selectedIdsSet: new Set(),
|
||||
hasSelection: false,
|
||||
toggleSelection: () => {},
|
||||
setSelectedNovelIds: () => {},
|
||||
};
|
||||
|
||||
export const SelectionContext =
|
||||
createContext<SelectionContextValue>(defaultValue);
|
||||
|
||||
export const useSelectionContext = () => useContext(SelectionContext);
|
||||
@@ -177,6 +177,12 @@ const ThirdRoute = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const bottomSheetSceneMap = SceneMap({
|
||||
first: FirstRoute,
|
||||
second: SecondRoute,
|
||||
third: ThirdRoute,
|
||||
});
|
||||
|
||||
const LibraryBottomSheet: React.FC<LibraryBottomSheetProps> = ({
|
||||
bottomSheetRef,
|
||||
style,
|
||||
@@ -185,24 +191,40 @@ const LibraryBottomSheet: React.FC<LibraryBottomSheetProps> = ({
|
||||
|
||||
const layout = useWindowDimensions();
|
||||
|
||||
const renderTabBar = (props: any) => (
|
||||
<TabBar
|
||||
{...props}
|
||||
indicatorStyle={{ backgroundColor: theme.primary }}
|
||||
style={[
|
||||
{
|
||||
backgroundColor: overlay(2, theme.surface),
|
||||
borderBottomColor: color(theme.isDark ? '#FFFFFF' : '#000000')
|
||||
.alpha(0.12)
|
||||
.string(),
|
||||
},
|
||||
styles.tabBar,
|
||||
style,
|
||||
]}
|
||||
inactiveColor={theme.onSurfaceVariant}
|
||||
activeColor={theme.primary}
|
||||
pressColor={color(theme.primary).alpha(0.12).string()}
|
||||
/>
|
||||
const borderBottomColor = useMemo(
|
||||
() =>
|
||||
color(theme.isDark ? '#FFFFFF' : '#000000')
|
||||
.alpha(0.12)
|
||||
.string(),
|
||||
[theme.isDark],
|
||||
);
|
||||
|
||||
const renderTabBar = useCallback(
|
||||
(props: any) => (
|
||||
<TabBar
|
||||
{...props}
|
||||
indicatorStyle={{ backgroundColor: theme.primary }}
|
||||
style={[
|
||||
{
|
||||
backgroundColor: overlay(2, theme.surface),
|
||||
borderBottomColor,
|
||||
},
|
||||
styles.tabBar,
|
||||
style,
|
||||
]}
|
||||
inactiveColor={theme.onSurfaceVariant}
|
||||
activeColor={theme.primary}
|
||||
pressColor={theme.rippleColor}
|
||||
/>
|
||||
),
|
||||
[
|
||||
theme.primary,
|
||||
theme.surface,
|
||||
theme.onSurfaceVariant,
|
||||
theme.rippleColor,
|
||||
borderBottomColor,
|
||||
style,
|
||||
],
|
||||
);
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
@@ -215,12 +237,6 @@ const LibraryBottomSheet: React.FC<LibraryBottomSheetProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const renderScene = SceneMap({
|
||||
first: FirstRoute,
|
||||
second: SecondRoute,
|
||||
third: ThirdRoute,
|
||||
});
|
||||
|
||||
const renderCommonOptions = useCallback(
|
||||
({ route, color: col }: { route: any; color: string }) => (
|
||||
<Text style={{ color: col }}>{route.title}</Text>
|
||||
@@ -249,7 +265,7 @@ const LibraryBottomSheet: React.FC<LibraryBottomSheetProps> = ({
|
||||
commonOptions={commonOptions}
|
||||
navigationState={{ index, routes }}
|
||||
renderTabBar={renderTabBar}
|
||||
renderScene={renderScene}
|
||||
renderScene={bottomSheetSceneMap}
|
||||
onIndexChange={setIndex}
|
||||
initialLayout={{ width: layout.width }}
|
||||
style={styles.tabView}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { RefreshControl, StyleSheet, View } from 'react-native';
|
||||
import { xor } from 'lodash-es';
|
||||
|
||||
import { EmptyView } from '@components/index';
|
||||
import NovelCover from '@components/NovelCover';
|
||||
import NovelList, { NovelListRenderItem } from '@components/NovelList';
|
||||
import LibraryNovelItem from './LibraryNovelItem';
|
||||
|
||||
import { NovelInfo } from '@database/types';
|
||||
|
||||
@@ -13,13 +12,13 @@ import { useTheme } from '@hooks/persisted';
|
||||
import { LibraryScreenProps } from '@navigators/types';
|
||||
import ServiceManager from '@services/ServiceManager';
|
||||
import { getPlugin } from '@plugins/pluginManager';
|
||||
import { useSelectionContext } from '../SelectionContext';
|
||||
import { ImageRequestInit } from '@plugins/types';
|
||||
|
||||
interface Props {
|
||||
categoryId: number;
|
||||
categoryName: string;
|
||||
novels: NovelInfo[];
|
||||
selectedNovelIds: number[];
|
||||
setSelectedNovelIds: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
navigation: LibraryScreenProps['navigation'];
|
||||
pickAndImport: () => void;
|
||||
}
|
||||
@@ -27,93 +26,110 @@ interface Props {
|
||||
export const LibraryView: React.FC<Props> = ({
|
||||
categoryId,
|
||||
categoryName,
|
||||
selectedNovelIds,
|
||||
setSelectedNovelIds,
|
||||
pickAndImport,
|
||||
navigation,
|
||||
novels,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const renderItem = ({ item }: { item: NovelInfo }) => {
|
||||
const imageRequestInit = getPlugin(item.pluginId)?.imageRequestInit;
|
||||
const { selectedIdsSet, hasSelection, toggleSelection } =
|
||||
useSelectionContext();
|
||||
|
||||
return (
|
||||
<NovelCover
|
||||
const onNavigate = useCallback(
|
||||
(item: NovelInfo) => {
|
||||
navigation.navigate('ReaderStack', {
|
||||
screen: 'Novel',
|
||||
params: item,
|
||||
});
|
||||
},
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const imageRequestInitMap = useMemo(() => {
|
||||
const map = new Map<string, ImageRequestInit | undefined>();
|
||||
for (const novel of novels) {
|
||||
if (!map.has(novel.pluginId)) {
|
||||
map.set(novel.pluginId, getPlugin(novel.pluginId)?.imageRequestInit);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [novels]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: NovelInfo }) => (
|
||||
<LibraryNovelItem
|
||||
item={item}
|
||||
theme={theme}
|
||||
isSelected={selectedNovelIds.includes(item.id)}
|
||||
onLongPress={() =>
|
||||
setSelectedNovelIds(xor(selectedNovelIds, [item.id]))
|
||||
}
|
||||
onPress={() => {
|
||||
if (selectedNovelIds.length) {
|
||||
setSelectedNovelIds(xor(selectedNovelIds, [item.id]));
|
||||
} else {
|
||||
navigation.navigate('ReaderStack', {
|
||||
screen: 'Novel',
|
||||
params: item,
|
||||
});
|
||||
}
|
||||
}}
|
||||
libraryStatus={false} // yes but actually no :D
|
||||
selectedNovelIds={selectedNovelIds}
|
||||
imageRequestInit={imageRequestInit}
|
||||
isSelected={selectedIdsSet.has(item.id)}
|
||||
hasSelection={hasSelection}
|
||||
onSelect={toggleSelection}
|
||||
onNavigate={onNavigate}
|
||||
imageRequestInit={imageRequestInitMap.get(item.pluginId)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
),
|
||||
[
|
||||
theme,
|
||||
selectedIdsSet,
|
||||
hasSelection,
|
||||
toggleSelection,
|
||||
onNavigate,
|
||||
imageRequestInitMap,
|
||||
],
|
||||
);
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const onRefresh = () => {
|
||||
// local category
|
||||
const onRefresh = useCallback(() => {
|
||||
if (categoryId === 2) {
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
ServiceManager.manager.addTask({
|
||||
name: 'UPDATE_LIBRARY',
|
||||
data: {
|
||||
categoryId,
|
||||
categoryName,
|
||||
},
|
||||
data: { categoryId, categoryName },
|
||||
});
|
||||
setRefreshing(false);
|
||||
};
|
||||
}, [categoryId, categoryName]);
|
||||
|
||||
const listEmptyComponent = useMemo(
|
||||
() => (
|
||||
<EmptyView
|
||||
theme={theme}
|
||||
icon="Σ(ಠ_ಠ)"
|
||||
description={getString('libraryScreen.empty')}
|
||||
actions={[
|
||||
categoryId !== 2
|
||||
? {
|
||||
iconName: 'compass-outline',
|
||||
title: getString('browse'),
|
||||
onPress: () => navigation.navigate('Browse'),
|
||||
}
|
||||
: {
|
||||
iconName: 'book-arrow-up-outline',
|
||||
title: getString('advancedSettingsScreen.importEpub'),
|
||||
onPress: pickAndImport,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
[theme, categoryId, navigation, pickAndImport],
|
||||
);
|
||||
|
||||
const refreshControl = useMemo(
|
||||
() => (
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
onRefresh={onRefresh}
|
||||
colors={[theme.onPrimary]}
|
||||
progressBackgroundColor={theme.primary}
|
||||
/>
|
||||
),
|
||||
[onRefresh, theme.onPrimary, theme.primary],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.flex}>
|
||||
<NovelList
|
||||
data={novels}
|
||||
extraData={[selectedNovelIds]}
|
||||
extraData={selectedIdsSet}
|
||||
renderItem={renderItem as NovelListRenderItem}
|
||||
ListEmptyComponent={
|
||||
<EmptyView
|
||||
theme={theme}
|
||||
icon="Σ(ಠ_ಠ)"
|
||||
description={getString('libraryScreen.empty')}
|
||||
actions={[
|
||||
categoryId !== 2
|
||||
? {
|
||||
iconName: 'compass-outline',
|
||||
title: getString('browse'),
|
||||
onPress: () => navigation.navigate('Browse'),
|
||||
}
|
||||
: {
|
||||
iconName: 'book-arrow-up-outline',
|
||||
title: getString('advancedSettingsScreen.importEpub'),
|
||||
onPress: pickAndImport,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[theme.onPrimary]}
|
||||
progressBackgroundColor={theme.primary}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={listEmptyComponent}
|
||||
refreshControl={refreshControl}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import NovelCover from '@components/NovelCover';
|
||||
import { NovelInfo } from '@database/types';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { ImageRequestInit } from '@plugins/types';
|
||||
|
||||
interface LibraryNovelItemProps {
|
||||
item: NovelInfo;
|
||||
theme: ThemeColors;
|
||||
isSelected: boolean;
|
||||
hasSelection: boolean;
|
||||
onSelect: (id: number) => void;
|
||||
onNavigate: (item: NovelInfo) => void;
|
||||
imageRequestInit: ImageRequestInit | undefined;
|
||||
}
|
||||
|
||||
const LibraryNovelItem = memo(function LibraryNovelItem({
|
||||
item,
|
||||
theme,
|
||||
isSelected,
|
||||
hasSelection,
|
||||
onSelect,
|
||||
onNavigate,
|
||||
imageRequestInit,
|
||||
}: LibraryNovelItemProps) {
|
||||
const handleLongPress = useCallback(() => {
|
||||
onSelect(item.id);
|
||||
}, [item.id, onSelect]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (hasSelection) {
|
||||
onSelect(item.id);
|
||||
} else {
|
||||
onNavigate(item);
|
||||
}
|
||||
}, [hasSelection, item, onSelect, onNavigate]);
|
||||
|
||||
return (
|
||||
<NovelCover
|
||||
item={item}
|
||||
theme={theme}
|
||||
isSelected={isSelected}
|
||||
hasSelection={hasSelection}
|
||||
onLongPress={handleLongPress}
|
||||
onPress={handlePress}
|
||||
libraryStatus={false}
|
||||
imageRequestInit={imageRequestInit}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default LibraryNovelItem;
|
||||
@@ -78,12 +78,18 @@ export const useLibrary = (): UseLibraryReturnType => {
|
||||
setIsLoading(false);
|
||||
}, [downloadedOnlyMode, filter, refreshCategories, searchText, sortOrder]);
|
||||
|
||||
const libraryLookup = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const novel of library) {
|
||||
set.add(`${novel.pluginId}::${novel.path}`);
|
||||
}
|
||||
return set;
|
||||
}, [library]);
|
||||
|
||||
const novelInLibrary = useCallback(
|
||||
(pluginId: string, novelPath: string) =>
|
||||
library?.some(
|
||||
novel => novel.pluginId === pluginId && novel.path === novelPath,
|
||||
),
|
||||
[library],
|
||||
libraryLookup.has(`${pluginId}::${novelPath}`),
|
||||
[libraryLookup],
|
||||
);
|
||||
|
||||
const switchNovelToLibrary = useCallback(
|
||||
@@ -92,8 +98,8 @@ export const useLibrary = (): UseLibraryReturnType => {
|
||||
|
||||
// Important to get correct chapters count
|
||||
// Count is set by sql trigger
|
||||
refreshCategories();
|
||||
const novels = getLibraryNovelsFromDb(
|
||||
await refreshCategories();
|
||||
const novels = await getLibraryNovelsFromDb(
|
||||
sortOrder,
|
||||
filter,
|
||||
searchText,
|
||||
@@ -136,7 +142,7 @@ export const useLibrary = (): UseLibraryReturnType => {
|
||||
getLibrary();
|
||||
}
|
||||
prevRestoreTasksCountRef.current = restoreTasksCount;
|
||||
}, [restoreTasksCount]);
|
||||
}, [getLibrary, restoreTasksCount]);
|
||||
|
||||
return {
|
||||
library,
|
||||
@@ -156,7 +162,7 @@ export const useLibraryNovels = () => {
|
||||
const [library, setLibrary] = useState<NovelInfo[]>([]);
|
||||
|
||||
const getLibrary = async () => {
|
||||
const novels = getLibraryNovelsFromDb();
|
||||
const novels = await getLibraryNovelsFromDb();
|
||||
|
||||
setLibrary(novels);
|
||||
};
|
||||
|
||||
@@ -51,7 +51,7 @@ const AboutScreen = ({ navigation }: AboutScreenProps) => {
|
||||
title={getString('aboutScreen.whatsNew')}
|
||||
onPress={() =>
|
||||
Linking.openURL(
|
||||
`https://github.com/LNReader/lnreader/releases/tag/v${version}`,
|
||||
`https://github.com/lnreader/lnreader/releases/tag/v${version}`,
|
||||
)
|
||||
}
|
||||
theme={theme}
|
||||
@@ -71,17 +71,17 @@ const AboutScreen = ({ navigation }: AboutScreenProps) => {
|
||||
/>
|
||||
<List.Item
|
||||
title={getString('aboutScreen.github')}
|
||||
description="https://github.com/LNReader/lnreader"
|
||||
description="https://github.com/lnreader/lnreader"
|
||||
onPress={() =>
|
||||
Linking.openURL('https://github.com/LNReader/lnreader')
|
||||
Linking.openURL('https://github.com/lnreader/lnreader')
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item
|
||||
title={getString('aboutScreen.sources')}
|
||||
description="https://github.com/LNReader/lnreader-sources"
|
||||
title={getString('aboutScreen.plugins')}
|
||||
description="https://github.com/lnreader/lnreader-plugins"
|
||||
onPress={() =>
|
||||
Linking.openURL('https://github.com/LNReader/lnreader-sources')
|
||||
Linking.openURL('https://github.com/lnreader/lnreader-plugins')
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
|
||||
@@ -86,7 +86,6 @@ const MoreScreen = ({ navigation }: MoreStackScreenProps) => {
|
||||
<Switch
|
||||
value={downloadedOnlyMode}
|
||||
onValueChange={enableDownloadedOnlyMode}
|
||||
size={24}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
@@ -120,7 +119,6 @@ const MoreScreen = ({ navigation }: MoreStackScreenProps) => {
|
||||
<Switch
|
||||
value={incognitoMode}
|
||||
onValueChange={enableIncognitoMode}
|
||||
size={24}
|
||||
/>
|
||||
</Pressable>
|
||||
<List.Divider theme={theme} />
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
FAB,
|
||||
ProgressBar,
|
||||
Appbar as MaterialAppbar,
|
||||
Menu,
|
||||
overlay,
|
||||
} from 'react-native-paper';
|
||||
|
||||
@@ -12,7 +11,7 @@ import { useTheme } from '@hooks/persisted';
|
||||
|
||||
import { showToast } from '../../utils/showToast';
|
||||
import { getString } from '@strings/translations';
|
||||
import { Appbar, EmptyView, SafeAreaView } from '@components';
|
||||
import { Appbar, EmptyView, Menu, SafeAreaView } from '@components';
|
||||
import { TaskQueueScreenProps } from '@navigators/types';
|
||||
import ServiceManager, { QueuedBackgroundTask } from '@services/ServiceManager';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
@@ -40,6 +40,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
fetching,
|
||||
batchInformation,
|
||||
getNextChapterBatch,
|
||||
loadUpToBatch,
|
||||
setNovel,
|
||||
bookmarkChapters,
|
||||
markChaptersRead,
|
||||
@@ -62,15 +63,6 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
|
||||
const headerOpacity = useSharedValue(0);
|
||||
|
||||
// TODO: fix this
|
||||
// useEffect(() => {
|
||||
// if (chapters.length !== 0 && !fetching) {
|
||||
// refreshChapters();
|
||||
// }
|
||||
// }, [chapters.length, downloadQueue.length, fetching, refreshChapters]);
|
||||
|
||||
// useFocusEffect(refreshChapters);
|
||||
|
||||
const downloadChs = useCallback(
|
||||
async (amount: number | 'all' | 'unread') => {
|
||||
if (!novel) {
|
||||
@@ -105,17 +97,19 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
},
|
||||
[chapters, downloadChapters, novel],
|
||||
);
|
||||
|
||||
const deleteChs = useCallback(() => {
|
||||
deleteChapters(chapters.filter(c => c.isDownloaded));
|
||||
}, [chapters, deleteChapters]);
|
||||
const shareNovel = () => {
|
||||
|
||||
const shareNovel = useCallback(() => {
|
||||
if (!novel) {
|
||||
return;
|
||||
}
|
||||
Share.share({
|
||||
message: resolveUrl(novel.pluginId, novel.path, true),
|
||||
});
|
||||
};
|
||||
}, [novel]);
|
||||
|
||||
const [jumpToChapterModal, showJumpToChapterModal] = useState(false);
|
||||
const {
|
||||
@@ -217,7 +211,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
selected,
|
||||
]);
|
||||
|
||||
const setCustomNovelCover = async () => {
|
||||
const setCustomNovelCover = useCallback(async () => {
|
||||
if (!novel) {
|
||||
return;
|
||||
}
|
||||
@@ -228,12 +222,58 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
cover: newCover,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [novel, setNovel]);
|
||||
|
||||
const stableGetNextBatch = useMemo(
|
||||
() =>
|
||||
batchInformation.batch < batchInformation.total && !fetching
|
||||
? getNextChapterBatch
|
||||
: noop,
|
||||
[batchInformation.batch, batchInformation.total, fetching, getNextChapterBatch],
|
||||
);
|
||||
|
||||
const hideJumpToChapterModal = useCallback(
|
||||
() => showJumpToChapterModal(false),
|
||||
[],
|
||||
);
|
||||
const hideEditInfoModal = useCallback(
|
||||
() => showEditInfoModal(false),
|
||||
[],
|
||||
);
|
||||
const clearSelection = useCallback(() => setSelected([]), []);
|
||||
const selectAll = useCallback(() => setSelected(chapters), [chapters]);
|
||||
|
||||
const snackbarTheme = useMemo(
|
||||
() => ({ colors: { primary: theme.primary } }),
|
||||
[theme.primary],
|
||||
);
|
||||
const snackbarTextStyle = useMemo(
|
||||
() => ({ color: theme.onSurface }),
|
||||
[theme.onSurface],
|
||||
);
|
||||
const titleStyle = useMemo(
|
||||
() => ({ color: theme.onSurface }),
|
||||
[theme.onSurface],
|
||||
);
|
||||
const snackbarAction = useMemo(
|
||||
() => ({
|
||||
label: getString('common.delete'),
|
||||
onPress: () => {
|
||||
deleteChapters(chapters.filter(c => c.isDownloaded));
|
||||
},
|
||||
}),
|
||||
[chapters, deleteChapters],
|
||||
);
|
||||
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, { backgroundColor: theme.background }],
|
||||
[styles.container, theme.background],
|
||||
);
|
||||
|
||||
return (
|
||||
<Portal.Host>
|
||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<View style={containerStyle}>
|
||||
<Portal>
|
||||
{selected.length === 0 ? (
|
||||
<NovelAppbar
|
||||
@@ -259,18 +299,16 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
<Appbar.Action
|
||||
icon="close"
|
||||
iconColor={theme.onBackground}
|
||||
onPress={() => setSelected([])}
|
||||
onPress={clearSelection}
|
||||
/>
|
||||
<Appbar.Content
|
||||
title={`${selected.length}`}
|
||||
titleStyle={{ color: theme.onSurface }}
|
||||
titleStyle={titleStyle}
|
||||
/>
|
||||
<Appbar.Action
|
||||
icon="select-all"
|
||||
iconColor={theme.onBackground}
|
||||
onPress={() => {
|
||||
setSelected(chapters);
|
||||
}}
|
||||
onPress={selectAll}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
@@ -284,11 +322,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
routeBaseNovel={route.params}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
getNextChapterBatch={
|
||||
batchInformation.batch < batchInformation.total && !fetching
|
||||
? getNextChapterBatch
|
||||
: noop
|
||||
}
|
||||
getNextChapterBatch={stableGetNextBatch}
|
||||
/>
|
||||
</Suspense>
|
||||
</SafeAreaView>
|
||||
@@ -298,33 +332,31 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
<Snackbar
|
||||
visible={deleteDownloadsSnackbar.value}
|
||||
onDismiss={deleteDownloadsSnackbar.setFalse}
|
||||
action={{
|
||||
label: getString('common.delete'),
|
||||
onPress: () => {
|
||||
deleteChapters(chapters.filter(c => c.isDownloaded));
|
||||
},
|
||||
}}
|
||||
theme={{ colors: { primary: theme.primary } }}
|
||||
action={snackbarAction}
|
||||
theme={snackbarTheme}
|
||||
style={styles.snackbar}
|
||||
>
|
||||
<Text style={{ color: theme.onSurface }}>
|
||||
<Text style={snackbarTextStyle}>
|
||||
{getString('novelScreen.deleteMessage')}
|
||||
</Text>
|
||||
</Snackbar>
|
||||
</Portal>
|
||||
<Portal>
|
||||
{novel && (
|
||||
{novel ? (
|
||||
<>
|
||||
<JumpToChapterModal
|
||||
modalVisible={jumpToChapterModal}
|
||||
hideModal={() => showJumpToChapterModal(false)}
|
||||
hideModal={hideJumpToChapterModal}
|
||||
novel={novel}
|
||||
chapterListRef={chapterListRef}
|
||||
navigation={navigation}
|
||||
loadUpToBatch={loadUpToBatch}
|
||||
totalChapters={batchInformation.totalChapters}
|
||||
chapters={chapters}
|
||||
/>
|
||||
<EditInfoModal
|
||||
modalVisible={editInfoModal}
|
||||
hideModal={() => showEditInfoModal(false)}
|
||||
hideModal={hideEditInfoModal}
|
||||
novel={novel}
|
||||
setNovel={setNovel}
|
||||
theme={theme}
|
||||
@@ -338,7 +370,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
|
||||
downloadChapters={downloadChapters}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</Portal>
|
||||
</View>
|
||||
</Portal.Host>
|
||||
|
||||
@@ -1,92 +1,72 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { MD3ThemeType } from '@theme/types';
|
||||
import React, { useMemo } from 'react';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { Menu, overlay } from 'react-native-paper';
|
||||
import { overlay } from 'react-native-paper';
|
||||
import { getString } from '@strings/translations';
|
||||
import { isChapterDownloaded } from '@database/queries/ChapterQueries';
|
||||
import { useBoolean } from '@hooks/index';
|
||||
import { IconButtonV2 } from '@components';
|
||||
import { IconButtonV2, Menu } from '@components';
|
||||
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
|
||||
import Color from 'color';
|
||||
|
||||
interface DownloadButtonProps {
|
||||
chapterId: number;
|
||||
isDownloaded: boolean;
|
||||
isDownloading?: boolean;
|
||||
theme: MD3ThemeType;
|
||||
theme: ThemeColors;
|
||||
deleteChapter: () => void;
|
||||
downloadChapter: () => void;
|
||||
setChapterDownloaded?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const DownloadButton: React.FC<DownloadButtonProps> = ({
|
||||
chapterId,
|
||||
isDownloaded,
|
||||
isDownloading,
|
||||
theme,
|
||||
deleteChapter,
|
||||
downloadChapter,
|
||||
setChapterDownloaded,
|
||||
}) => {
|
||||
const [downloaded, setDownloaded] = React.useState<boolean | undefined>(
|
||||
isDownloaded,
|
||||
);
|
||||
|
||||
const {
|
||||
value: deleteChapterMenuVisible,
|
||||
setTrue: showDeleteChapterMenu,
|
||||
setFalse: hideDeleteChapterMenu,
|
||||
} = useBoolean();
|
||||
|
||||
const isFirstRender = useRef(true);
|
||||
const menuContentStyle = useMemo(
|
||||
() => ({ backgroundColor: overlay(2, theme.surface) }),
|
||||
[theme.surface],
|
||||
);
|
||||
const menuTitleStyle = useMemo(
|
||||
() => ({ color: theme.onSurface }),
|
||||
[theme.onSurface],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return; // Skip the first render as it leads to 'Maximum update depth exceeded.' error
|
||||
}
|
||||
if (!isDownloading) {
|
||||
const isDownloadedValue = isChapterDownloaded(chapterId);
|
||||
setDownloaded(isDownloadedValue);
|
||||
setChapterDownloaded?.(isDownloadedValue);
|
||||
}
|
||||
}, [chapterId, isDownloading, setChapterDownloaded]);
|
||||
if (isDownloading || downloaded === undefined) {
|
||||
if (isDownloading) {
|
||||
return <ChapterDownloadingButton theme={theme} />;
|
||||
}
|
||||
return downloaded ? (
|
||||
<Menu
|
||||
visible={deleteChapterMenuVisible}
|
||||
onDismiss={hideDeleteChapterMenu}
|
||||
anchor={
|
||||
<DeleteChapterButton theme={theme} onPress={showDeleteChapterMenu} />
|
||||
}
|
||||
contentStyle={{ backgroundColor: overlay(2, theme.surface) }}
|
||||
>
|
||||
<Menu.Item
|
||||
onPress={() => {
|
||||
deleteChapter();
|
||||
hideDeleteChapterMenu();
|
||||
setDownloaded(false);
|
||||
}}
|
||||
title={getString('common.delete')}
|
||||
titleStyle={{ color: theme.onSurface }}
|
||||
/>
|
||||
</Menu>
|
||||
) : (
|
||||
<DownloadChapterButton
|
||||
theme={theme}
|
||||
onPress={() => {
|
||||
downloadChapter();
|
||||
setDownloaded(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
if (isDownloaded) {
|
||||
return (
|
||||
<Menu
|
||||
visible={deleteChapterMenuVisible}
|
||||
onDismiss={hideDeleteChapterMenu}
|
||||
anchor={
|
||||
<DeleteChapterButton theme={theme} onPress={showDeleteChapterMenu} />
|
||||
}
|
||||
contentStyle={menuContentStyle}
|
||||
>
|
||||
<Menu.Item
|
||||
onPress={() => {
|
||||
deleteChapter();
|
||||
hideDeleteChapterMenu();
|
||||
}}
|
||||
title={getString('common.delete')}
|
||||
titleStyle={menuTitleStyle}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
return <DownloadChapterButton theme={theme} onPress={downloadChapter} />;
|
||||
};
|
||||
|
||||
interface theme {
|
||||
theme: MD3ThemeType;
|
||||
theme: ThemeColors;
|
||||
}
|
||||
type buttonPropType = theme & {
|
||||
onPress: () => void;
|
||||
@@ -117,7 +97,7 @@ export const DownloadChapterButton: React.FC<buttonPropType> = ({
|
||||
<Pressable
|
||||
style={styles.pressable}
|
||||
onPress={onPress}
|
||||
android_ripple={{ color: Color(theme.primary).alpha(0.12).string() }}
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
>
|
||||
<DownloadIcon theme={theme} />
|
||||
</Pressable>
|
||||
@@ -140,7 +120,7 @@ export const DeleteChapterButton: React.FC<buttonPropType> = ({
|
||||
<Pressable
|
||||
style={styles.pressable}
|
||||
onPress={onPress}
|
||||
android_ripple={{ color: Color(theme.primary).alpha(0.12).string() }}
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
>
|
||||
<DeleteIcon theme={theme} />
|
||||
</Pressable>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/* eslint-disable react-native/no-inline-styles */
|
||||
import React, { memo, ReactNode } from 'react';
|
||||
import React, { memo, useCallback, useMemo, ReactNode } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import color from 'color';
|
||||
import {
|
||||
ChapterBookmarkButton,
|
||||
DownloadButton,
|
||||
@@ -12,88 +10,113 @@ import MaterialCommunityIcons from '@react-native-vector-icons/material-design-i
|
||||
import { getString } from '@strings/translations';
|
||||
|
||||
interface ChapterItemProps {
|
||||
chapter: ChapterInfo;
|
||||
isDownloading?: boolean;
|
||||
isBookmarked?: boolean;
|
||||
chapter: ChapterInfo;
|
||||
theme: ThemeColors;
|
||||
showChapterTitles: boolean;
|
||||
isSelected?: boolean;
|
||||
downloadChapter: () => void;
|
||||
deleteChapter: () => void;
|
||||
onSelectPress?: (chapter: ChapterInfo) => void;
|
||||
onSelectLongPress?: (chapter: ChapterInfo) => void;
|
||||
navigateToChapter: (chapter: ChapterInfo) => void;
|
||||
setChapterDownloaded?: (value: boolean) => void;
|
||||
left?: ReactNode;
|
||||
isLocal: boolean;
|
||||
isUpdateCard?: boolean;
|
||||
theme: ThemeColors;
|
||||
showChapterTitles: boolean;
|
||||
novelName: string;
|
||||
left?: ReactNode;
|
||||
onDeleteChapter: (chapter: ChapterInfo) => void;
|
||||
onDownloadChapter: (chapter: ChapterInfo) => void;
|
||||
onSelectPress: (chapter: ChapterInfo) => void;
|
||||
onSelectLongPress?: (chapter: ChapterInfo) => void;
|
||||
}
|
||||
|
||||
const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
chapter,
|
||||
isDownloading,
|
||||
isBookmarked,
|
||||
chapter,
|
||||
isSelected,
|
||||
isLocal,
|
||||
isUpdateCard,
|
||||
theme,
|
||||
showChapterTitles,
|
||||
downloadChapter,
|
||||
deleteChapter,
|
||||
isSelected,
|
||||
novelName,
|
||||
left,
|
||||
onDeleteChapter,
|
||||
onDownloadChapter,
|
||||
onSelectPress,
|
||||
onSelectLongPress,
|
||||
navigateToChapter,
|
||||
setChapterDownloaded,
|
||||
isLocal,
|
||||
left,
|
||||
isUpdateCard,
|
||||
novelName,
|
||||
}) => {
|
||||
const { id, name, unread, releaseTime, bookmark, chapterNumber, progress } =
|
||||
chapter;
|
||||
|
||||
isBookmarked ??= bookmark;
|
||||
|
||||
const handlePress = useCallback(
|
||||
() => onSelectPress(chapter),
|
||||
[onSelectPress, chapter],
|
||||
);
|
||||
const handleLongPress = useCallback(
|
||||
() => onSelectLongPress?.(chapter),
|
||||
[onSelectLongPress, chapter],
|
||||
);
|
||||
const handleDelete = useCallback(
|
||||
() => onDeleteChapter(chapter),
|
||||
[onDeleteChapter, chapter],
|
||||
);
|
||||
const handleDownload = useCallback(
|
||||
() => onDownloadChapter(chapter),
|
||||
[onDownloadChapter, chapter],
|
||||
);
|
||||
|
||||
const selectedStyle = useMemo(
|
||||
() =>
|
||||
isSelected
|
||||
? [styles.chapterCardContainer, { backgroundColor: theme.rippleColor }]
|
||||
: styles.chapterCardContainer,
|
||||
[isSelected, theme.rippleColor],
|
||||
);
|
||||
|
||||
const titleColor = useMemo(
|
||||
() =>
|
||||
!unread ? theme.outline : bookmark ? theme.primary : theme.onSurface,
|
||||
[unread, bookmark, theme.outline, theme.primary, theme.onSurface],
|
||||
);
|
||||
|
||||
const releaseColor = useMemo(
|
||||
() =>
|
||||
!unread
|
||||
? theme.outline
|
||||
: bookmark
|
||||
? theme.primary
|
||||
: theme.onSurfaceVariant,
|
||||
[unread, bookmark, theme.outline, theme.primary, theme.onSurfaceVariant],
|
||||
);
|
||||
|
||||
const ripple = useMemo(
|
||||
() => ({ color: theme.rippleColor }),
|
||||
[theme.rippleColor],
|
||||
);
|
||||
|
||||
return (
|
||||
<View key={'chapterItem' + id}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.chapterCardContainer,
|
||||
isSelected && {
|
||||
backgroundColor: color(theme.primary).alpha(0.12).string(),
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
if (onSelectPress) {
|
||||
onSelectPress(chapter);
|
||||
} else {
|
||||
navigateToChapter(chapter);
|
||||
}
|
||||
}}
|
||||
onLongPress={() => onSelectLongPress?.(chapter)}
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
style={selectedStyle}
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
android_ripple={ripple}
|
||||
>
|
||||
<View style={styles.row}>
|
||||
{left}
|
||||
{isBookmarked ? <ChapterBookmarkButton theme={theme} /> : null}
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={styles.flex1}>
|
||||
{isUpdateCard ? (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: unread ? theme.onSurface : theme.outline,
|
||||
}}
|
||||
style={[
|
||||
styles.updateCardName,
|
||||
{ color: unread ? theme.onSurface : theme.outline },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{novelName}
|
||||
</Text>
|
||||
) : null}
|
||||
<View
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<View style={styles.titleRow}>
|
||||
{unread ? (
|
||||
<MaterialCommunityIcons
|
||||
name="circle"
|
||||
@@ -104,15 +127,11 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
) : null}
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: isUpdateCard ? 12 : 14,
|
||||
color: !unread
|
||||
? theme.outline
|
||||
: bookmark
|
||||
? theme.primary
|
||||
: theme.onSurface,
|
||||
flex: 1,
|
||||
}}
|
||||
style={[
|
||||
isUpdateCard ? styles.textSmall : styles.textNormal,
|
||||
{ color: titleColor },
|
||||
styles.flex1,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
@@ -123,23 +142,11 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<View style={styles.metaRow}>
|
||||
{releaseTime && !isUpdateCard ? (
|
||||
<Text
|
||||
style={[
|
||||
{
|
||||
color: !unread
|
||||
? theme.outline
|
||||
: bookmark
|
||||
? theme.primary
|
||||
: theme.onSurfaceVariant,
|
||||
marginTop: 4,
|
||||
},
|
||||
{ color: releaseColor, marginTop: 4 },
|
||||
styles.text,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
@@ -149,12 +156,14 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
) : null}
|
||||
{!isUpdateCard && progress && progress > 0 && chapter.unread ? (
|
||||
<Text
|
||||
style={{
|
||||
color: theme.outline,
|
||||
marginLeft: chapter.releaseTime ? 5 : 0,
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
}}
|
||||
style={[
|
||||
styles.text,
|
||||
{
|
||||
color: theme.outline,
|
||||
marginStart: chapter.releaseTime ? 5 : 0,
|
||||
marginTop: 4,
|
||||
},
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{chapter.releaseTime ? '• ' : null}
|
||||
@@ -168,11 +177,9 @@ const ChapterItem: React.FC<ChapterItemProps> = ({
|
||||
<DownloadButton
|
||||
isDownloading={isDownloading}
|
||||
isDownloaded={chapter.isDownloaded}
|
||||
chapterId={chapter.id}
|
||||
theme={theme}
|
||||
setChapterDownloaded={setChapterDownloaded}
|
||||
deleteChapter={deleteChapter}
|
||||
downloadChapter={downloadChapter}
|
||||
deleteChapter={handleDelete}
|
||||
downloadChapter={handleDownload}
|
||||
/>
|
||||
) : null}
|
||||
</Pressable>
|
||||
@@ -191,6 +198,13 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
flex1: {
|
||||
flex: 1,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
row: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
@@ -199,7 +213,20 @@ const styles = StyleSheet.create({
|
||||
text: {
|
||||
fontSize: 12,
|
||||
},
|
||||
textNormal: {
|
||||
fontSize: 14,
|
||||
},
|
||||
textSmall: {
|
||||
fontSize: 12,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
unreadIcon: {
|
||||
marginRight: 4,
|
||||
marginEnd: 4,
|
||||
},
|
||||
updateCardName: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -137,7 +137,7 @@ const ExportNovelAsEpubButton: React.FC<ExportNovelAsEpubButtonProps> = ({
|
||||
epub = new EpubBuilder(
|
||||
{
|
||||
title: novel.name,
|
||||
fileName: novel.name.replace(/\s/g, ''),
|
||||
fileName: novel.name.replace(/[\\/:*?"<>|\s]/g, '') || 'novel',
|
||||
language: 'en',
|
||||
cover: novel.cover,
|
||||
description: novel.summary,
|
||||
@@ -157,7 +157,37 @@ const ExportNovelAsEpubButton: React.FC<ExportNovelAsEpubButtonProps> = ({
|
||||
const chapterFilePath = `${NOVEL_STORAGE}/${novel.pluginId}/${novel.id}/${chapter.id}/index.html`;
|
||||
|
||||
if (NativeFile.exists(chapterFilePath)) {
|
||||
const chapterContent = NativeFile.readFile(chapterFilePath);
|
||||
let chapterContent = NativeFile.readFile(chapterFilePath);
|
||||
|
||||
const chapterDir = `${NOVEL_STORAGE}/${novel.pluginId}/${novel.id}/${chapter.id}`;
|
||||
const escapedDir = chapterDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const imagePathRegex = new RegExp(
|
||||
`file://(${escapedDir}/[^"'\\s]+)`,
|
||||
'g',
|
||||
);
|
||||
|
||||
for (const match of chapterContent.matchAll(imagePathRegex)) {
|
||||
const imagePath = match[1];
|
||||
if (imagePath && !NativeFile.exists(imagePath)) {
|
||||
const escapedPath = imagePath.replace(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
'\\$&',
|
||||
);
|
||||
const figureRegex = new RegExp(
|
||||
`<figure[^>]*>.*?${escapedPath}.*?</figure>`,
|
||||
'gs',
|
||||
);
|
||||
|
||||
chapterContent = chapterContent.replace(figureRegex, '');
|
||||
|
||||
const imgRegex = new RegExp(
|
||||
`<img[^>]*${escapedPath}[^>]*\\/?>`,
|
||||
'g',
|
||||
);
|
||||
|
||||
chapterContent = chapterContent.replace(imgRegex, '');
|
||||
}
|
||||
}
|
||||
|
||||
await epub.addChapter({
|
||||
title:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
@@ -43,27 +43,31 @@ const NovelInfoContainer = ({ children }: { children: React.ReactNode }) => (
|
||||
<View style={styles.novelInfoContainer}>{children}</View>
|
||||
);
|
||||
|
||||
const CoverImage = ({
|
||||
children,
|
||||
source,
|
||||
theme,
|
||||
hideBackdrop,
|
||||
}: CoverImageProps) => {
|
||||
if (hideBackdrop) {
|
||||
return <View>{children}</View>;
|
||||
} else {
|
||||
const CoverImage = memo(
|
||||
({ children, source, theme, hideBackdrop }: CoverImageProps) => {
|
||||
const overlayBg = useMemo(
|
||||
() => color(theme.background).alpha(0.7).string(),
|
||||
[theme.background],
|
||||
);
|
||||
const overlayStyle = useMemo(
|
||||
() => [{ backgroundColor: overlayBg }, styles.flex1],
|
||||
[overlayBg],
|
||||
);
|
||||
const gradientColors = useMemo(
|
||||
() => ['rgba(0,0,0,0)', theme.background] as const,
|
||||
[theme.background],
|
||||
);
|
||||
|
||||
if (hideBackdrop) {
|
||||
return <View>{children}</View>;
|
||||
}
|
||||
return (
|
||||
<ImageBackground source={source} style={styles.coverImage}>
|
||||
<View
|
||||
style={[
|
||||
{ backgroundColor: color(theme.background).alpha(0.7).string() },
|
||||
styles.flex1,
|
||||
]}
|
||||
>
|
||||
<View style={overlayStyle}>
|
||||
{source.uri ? (
|
||||
<LinearGradient
|
||||
colors={['rgba(0,0,0,0)', theme.background]}
|
||||
locations={[0, 1]}
|
||||
colors={gradientColors}
|
||||
locations={GRADIENT_LOCATIONS}
|
||||
style={styles.linearGradient}
|
||||
>
|
||||
{children}
|
||||
@@ -74,8 +78,10 @@ const CoverImage = ({
|
||||
</View>
|
||||
</ImageBackground>
|
||||
);
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const GRADIENT_LOCATIONS = [0, 1] as const;
|
||||
|
||||
const NovelThumbnail = ({
|
||||
source,
|
||||
@@ -170,7 +176,7 @@ const FollowButton = ({
|
||||
<View style={styles.followButtonContainer}>
|
||||
<Pressable
|
||||
android_ripple={{
|
||||
color: color(theme.primary).alpha(0.12).string(),
|
||||
color: theme.rippleColor,
|
||||
borderless: false,
|
||||
}}
|
||||
onPress={onPress}
|
||||
@@ -234,26 +240,28 @@ const TrackerButton = ({
|
||||
</View>
|
||||
);
|
||||
|
||||
const NovelGenres = ({
|
||||
theme,
|
||||
genres,
|
||||
}: {
|
||||
theme: ThemeColors;
|
||||
genres: string;
|
||||
}) => {
|
||||
const data = genres.split(/,\s*/);
|
||||
const genreKeyExtractor = (_item: string, index: number) => 'genre' + index;
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
contentContainerStyle={styles.genreContainer}
|
||||
horizontal
|
||||
data={data}
|
||||
keyExtractor={(item, index) => 'genre' + index}
|
||||
renderItem={({ item }) => <Chip label={item} theme={theme} />}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const NovelGenres = memo(
|
||||
({ theme, genres }: { theme: ThemeColors; genres: string }) => {
|
||||
const data = useMemo(() => genres.split(/,\s*/), [genres]);
|
||||
const renderGenre = useCallback(
|
||||
({ item }: { item: string }) => <Chip label={item} theme={theme} />,
|
||||
[theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
contentContainerStyle={styles.genreContainer}
|
||||
horizontal
|
||||
data={data}
|
||||
keyExtractor={genreKeyExtractor}
|
||||
renderItem={renderGenre}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export {
|
||||
NovelInfoContainer,
|
||||
@@ -317,7 +325,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
novelInfo: {
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
},
|
||||
novelInfoContainer: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import color from 'color';
|
||||
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
|
||||
@@ -39,12 +38,24 @@ import {
|
||||
VerticalBarSkeleton,
|
||||
} from '@components/Skeleton/Skeleton';
|
||||
import { useNovelContext } from '@screens/novel/NovelContext';
|
||||
import Animated, {
|
||||
useAnimatedProps,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import useLoadingColors from '@components/Skeleton/useLoadingColors';
|
||||
|
||||
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
|
||||
|
||||
interface NovelInfoHeaderProps {
|
||||
chapters: ChapterInfo[];
|
||||
deleteDownloadsSnackbar: UseBooleanReturnType;
|
||||
fetching: boolean;
|
||||
filter: string;
|
||||
firstUnreadChapter?: ChapterInfo;
|
||||
isLoading: boolean;
|
||||
lastRead?: ChapterInfo;
|
||||
navigateToChapter: (chapter: ChapterInfo) => void;
|
||||
@@ -73,11 +84,155 @@ const getStatusIcon = (status?: string) => {
|
||||
return 'help';
|
||||
};
|
||||
|
||||
const ChapterCountSkeleton = ({ theme }: { theme: ThemeColors }) => {
|
||||
const sv = useSharedValue(0);
|
||||
const { disableLoadingAnimations } = useAppSettings();
|
||||
const [highlightColor, backgroundColor] = useLoadingColors(theme);
|
||||
|
||||
const animatedProps = useAnimatedProps(() => {
|
||||
return {
|
||||
left: (sv.value + '%') as `${number}%`,
|
||||
};
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (disableLoadingAnimations) return;
|
||||
sv.value = withRepeat(
|
||||
withSequence(0, withTiming(160, { duration: 1000 })),
|
||||
-1,
|
||||
);
|
||||
}, [disableLoadingAnimations, sv]);
|
||||
|
||||
if (disableLoadingAnimations) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.chapterCountSkeleton,
|
||||
{ backgroundColor: backgroundColor },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const LG = Animated.createAnimatedComponent(LinearGradient);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.chapterCountSkeleton,
|
||||
{ backgroundColor: backgroundColor },
|
||||
]}
|
||||
>
|
||||
<LG
|
||||
start={[0, 0]}
|
||||
end={[1, 0]}
|
||||
locations={[0, 0.3, 0.7, 1]}
|
||||
style={[animatedProps, styles.chapterCountGradient]}
|
||||
colors={['transparent', highlightColor, highlightColor, 'transparent']}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const useShimmer = (theme: ThemeColors) => {
|
||||
const sv = useSharedValue(0);
|
||||
const { disableLoadingAnimations } = useAppSettings();
|
||||
const [highlightColor, backgroundColor] = useLoadingColors(theme);
|
||||
|
||||
const animatedStyle = useAnimatedProps(() => ({
|
||||
left: (sv.value + '%') as `${number}%`,
|
||||
}));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (disableLoadingAnimations) return;
|
||||
sv.value = withRepeat(
|
||||
withSequence(0, withTiming(160, { duration: 1000 })),
|
||||
-1,
|
||||
);
|
||||
}, [disableLoadingAnimations, sv]);
|
||||
|
||||
return {
|
||||
animatedStyle,
|
||||
highlightColor,
|
||||
backgroundColor,
|
||||
disableLoadingAnimations,
|
||||
};
|
||||
};
|
||||
|
||||
const NovelDetailsSkeleton = ({ theme }: { theme: ThemeColors }) => {
|
||||
const {
|
||||
animatedStyle,
|
||||
highlightColor,
|
||||
backgroundColor,
|
||||
disableLoadingAnimations,
|
||||
} = useShimmer(theme);
|
||||
|
||||
const shimmer = !disableLoadingAnimations ? (
|
||||
<AnimatedLinearGradient
|
||||
start={[0, 0]}
|
||||
end={[1, 0]}
|
||||
locations={[0, 0.3, 0.7, 1]}
|
||||
style={[animatedStyle, styles.infoSkeletonGradient]}
|
||||
colors={['transparent', highlightColor, highlightColor, 'transparent']}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row style={styles.infoRow}>
|
||||
<View style={[styles.infoSkeletonBar, { backgroundColor, width: 130 }]}>
|
||||
{shimmer}
|
||||
</View>
|
||||
</Row>
|
||||
<Row style={styles.infoRow}>
|
||||
<View style={[styles.infoSkeletonBar, { backgroundColor, width: 180 }]}>
|
||||
{shimmer}
|
||||
</View>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ButtonGroupSkeleton = ({ theme }: { theme: ThemeColors }) => {
|
||||
const {
|
||||
animatedStyle,
|
||||
highlightColor,
|
||||
backgroundColor,
|
||||
disableLoadingAnimations,
|
||||
} = useShimmer(theme);
|
||||
|
||||
const shimmer = !disableLoadingAnimations ? (
|
||||
<AnimatedLinearGradient
|
||||
start={[0, 0]}
|
||||
end={[1, 0]}
|
||||
locations={[0, 0.3, 0.7, 1]}
|
||||
style={[animatedStyle, styles.buttonSkeletonGradient]}
|
||||
colors={['transparent', highlightColor, highlightColor, 'transparent']}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<View style={styles.buttonGroupSkeletonContainer}>
|
||||
<View style={[styles.buttonSkeleton, { backgroundColor }]}>
|
||||
{shimmer}
|
||||
</View>
|
||||
<View style={[styles.buttonSkeleton, { backgroundColor }]}>
|
||||
{shimmer}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const showNotAvailable = async () => {
|
||||
showToast('Not available while loading');
|
||||
};
|
||||
|
||||
const NovelInfoHeader = ({
|
||||
chapters,
|
||||
deleteDownloadsSnackbar,
|
||||
fetching,
|
||||
filter,
|
||||
firstUnreadChapter,
|
||||
isLoading = false,
|
||||
lastRead,
|
||||
navigateToChapter,
|
||||
@@ -101,20 +256,64 @@ const NovelInfoHeader = ({
|
||||
[novel.pluginId],
|
||||
);
|
||||
|
||||
const showNotAvailable = async () => {
|
||||
showToast('Not available while loading');
|
||||
};
|
||||
const coverSource = useMemo(() => ({ uri: novel.cover }), [novel.cover]);
|
||||
|
||||
const handleTitlePress = useCallback(
|
||||
() =>
|
||||
navigation.replace('GlobalSearchScreen', {
|
||||
searchText: novel.name,
|
||||
}),
|
||||
[navigation, novel.name],
|
||||
);
|
||||
|
||||
const handleTitleLongPress = useCallback(() => {
|
||||
Clipboard.setStringAsync(novel.name).then(() =>
|
||||
showToast(getString('common.copiedToClipboard', { name: novel.name })),
|
||||
);
|
||||
}, [novel.name]);
|
||||
|
||||
const handleFollowNovel = useCallback(() => {
|
||||
if (isLoading) {
|
||||
showNotAvailable();
|
||||
return;
|
||||
}
|
||||
followNovel();
|
||||
if (novel.inLibrary && chapters.some(chapter => chapter.isDownloaded)) {
|
||||
deleteDownloadsSnackbar.setTrue();
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
followNovel,
|
||||
novel.inLibrary,
|
||||
chapters,
|
||||
deleteDownloadsSnackbar,
|
||||
]);
|
||||
|
||||
const handleTrackerSheet = useCallback(
|
||||
() => trackerSheetRef.current?.present(),
|
||||
[trackerSheetRef],
|
||||
);
|
||||
|
||||
const handleOpenBottomSheet = useCallback(
|
||||
() => novelBottomSheetRef.current?.present(),
|
||||
[novelBottomSheetRef],
|
||||
);
|
||||
|
||||
const ripple = useMemo(
|
||||
() => ({ color: theme.rippleColor }),
|
||||
[theme.rippleColor],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CoverImage
|
||||
source={{ uri: novel.cover }}
|
||||
source={coverSource}
|
||||
theme={theme}
|
||||
hideBackdrop={hideBackdrop}
|
||||
>
|
||||
<NovelInfoContainer>
|
||||
<NovelThumbnail
|
||||
source={{ uri: novel.cover }}
|
||||
source={coverSource}
|
||||
theme={theme}
|
||||
setCustomNovelCover={
|
||||
isLoading ? showNotAvailable : setCustomNovelCover
|
||||
@@ -122,88 +321,74 @@ const NovelInfoHeader = ({
|
||||
saveNovelCover={isLoading ? showNotAvailable : saveNovelCover}
|
||||
/>
|
||||
<View style={styles.novelDetails}>
|
||||
<Row>
|
||||
<Row style={styles.infoRow}>
|
||||
<NovelTitle
|
||||
theme={theme}
|
||||
onPress={() =>
|
||||
navigation.replace('GlobalSearchScreen', {
|
||||
searchText: novel.name,
|
||||
})
|
||||
}
|
||||
onLongPress={() => {
|
||||
Clipboard.setStringAsync(novel.name).then(() =>
|
||||
showToast(
|
||||
getString('common.copiedToClipboard', {
|
||||
name: novel.name,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}}
|
||||
onPress={handleTitlePress}
|
||||
onLongPress={handleTitleLongPress}
|
||||
>
|
||||
{novel.name}
|
||||
</NovelTitle>
|
||||
</Row>
|
||||
{novel.id !== 'NO_ID' && novel.author ? (
|
||||
<Row>
|
||||
<MaterialCommunityIcons
|
||||
name="fountain-pen-tip"
|
||||
size={14}
|
||||
color={theme.onSurfaceVariant}
|
||||
style={styles.marginRight}
|
||||
/>
|
||||
<NovelInfo theme={theme}>{novel.author}</NovelInfo>
|
||||
</Row>
|
||||
) : null}
|
||||
{novel.id !== 'NO_ID' && novel.artist ? (
|
||||
<Row>
|
||||
<MaterialCommunityIcons
|
||||
name="palette-outline"
|
||||
size={14}
|
||||
color={theme.onSurfaceVariant}
|
||||
style={styles.marginRight}
|
||||
/>
|
||||
<NovelInfo theme={theme}>{novel.artist}</NovelInfo>
|
||||
</Row>
|
||||
) : null}
|
||||
<Row>
|
||||
<MaterialCommunityIcons
|
||||
name={getStatusIcon(
|
||||
novel.id !== 'NO_ID' ? novel.status : undefined,
|
||||
)}
|
||||
size={14}
|
||||
color={theme.onSurfaceVariant}
|
||||
style={styles.marginRight}
|
||||
/>
|
||||
<NovelInfo theme={theme}>
|
||||
{(novel.id !== 'NO_ID'
|
||||
? translateNovelStatus(novel.status)
|
||||
: getString('novelScreen.unknownStatus')) +
|
||||
' • ' +
|
||||
pluginName}
|
||||
</NovelInfo>
|
||||
</Row>
|
||||
{isLoading && novel.id === 'NO_ID' ? (
|
||||
<NovelDetailsSkeleton theme={theme} />
|
||||
) : (
|
||||
<>
|
||||
{novel.id !== 'NO_ID' && novel.author ? (
|
||||
<Row style={styles.infoRow}>
|
||||
<MaterialCommunityIcons
|
||||
name="fountain-pen-tip"
|
||||
size={14}
|
||||
color={theme.onSurfaceVariant}
|
||||
style={styles.marginRight}
|
||||
/>
|
||||
<NovelInfo theme={theme}>{novel.author}</NovelInfo>
|
||||
</Row>
|
||||
) : null}
|
||||
{novel.id !== 'NO_ID' && novel.artist ? (
|
||||
<Row style={styles.infoRow}>
|
||||
<MaterialCommunityIcons
|
||||
name="palette-outline"
|
||||
size={14}
|
||||
color={theme.onSurfaceVariant}
|
||||
style={styles.marginRight}
|
||||
/>
|
||||
<NovelInfo theme={theme}>{novel.artist}</NovelInfo>
|
||||
</Row>
|
||||
) : null}
|
||||
<Row style={styles.infoRow}>
|
||||
<MaterialCommunityIcons
|
||||
name={getStatusIcon(
|
||||
novel.id !== 'NO_ID' ? novel.status : undefined,
|
||||
)}
|
||||
size={14}
|
||||
color={theme.onSurfaceVariant}
|
||||
style={styles.marginRight}
|
||||
/>
|
||||
<NovelInfo theme={theme}>
|
||||
{(novel.id !== 'NO_ID'
|
||||
? translateNovelStatus(novel.status)
|
||||
: getString('novelScreen.unknownStatus')) +
|
||||
' • ' +
|
||||
pluginName}
|
||||
</NovelInfo>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</NovelInfoContainer>
|
||||
</CoverImage>
|
||||
<>
|
||||
<NovelScreenButtonGroup
|
||||
novel={novel}
|
||||
handleFollowNovel={
|
||||
isLoading
|
||||
? showNotAvailable
|
||||
: () => {
|
||||
followNovel();
|
||||
if (
|
||||
novel.inLibrary &&
|
||||
chapters.some(chapter => chapter.isDownloaded)
|
||||
) {
|
||||
deleteDownloadsSnackbar.setTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
handleTrackerSheet={() => trackerSheetRef.current?.present()}
|
||||
theme={theme}
|
||||
/>
|
||||
{isLoading && novel.id === 'NO_ID' ? (
|
||||
<ButtonGroupSkeleton theme={theme} />
|
||||
) : (
|
||||
<NovelScreenButtonGroup
|
||||
novel={novel}
|
||||
handleFollowNovel={handleFollowNovel}
|
||||
handleTrackerSheet={handleTrackerSheet}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (!novel.genres || !novel.summary) ? (
|
||||
<NovelMetaSkeleton />
|
||||
) : (
|
||||
@@ -220,7 +405,7 @@ const NovelInfoHeader = ({
|
||||
)}
|
||||
<ReadButton
|
||||
navigateToChapter={navigateToChapter}
|
||||
chapters={chapters}
|
||||
firstUnreadChapter={firstUnreadChapter}
|
||||
lastRead={lastRead}
|
||||
/>
|
||||
{isLoading && (!novel.genres || !novel.summary) ? (
|
||||
@@ -229,23 +414,23 @@ const NovelInfoHeader = ({
|
||||
<View style={styles.bottomsheetContainer}>
|
||||
<Pressable
|
||||
style={styles.bottomsheet}
|
||||
onPress={() => novelBottomSheetRef.current?.present()}
|
||||
android_ripple={{
|
||||
color: color(theme.primary).alpha(0.12).string(),
|
||||
}}
|
||||
onPress={handleOpenBottomSheet}
|
||||
android_ripple={ripple}
|
||||
>
|
||||
<View style={styles.flex}>
|
||||
<Text style={[{ color: theme.onSurface }, styles.chapters]}>
|
||||
{!fetching || totalChapters !== undefined
|
||||
? `${totalChapters} ${getString('novelScreen.chapters')}`
|
||||
: getString('common.loading')}
|
||||
</Text>
|
||||
{fetching && totalChapters === undefined ? (
|
||||
<ChapterCountSkeleton theme={theme} />
|
||||
) : (
|
||||
<Text style={[{ color: theme.onSurface }, styles.chapters]}>
|
||||
{`${totalChapters} ${getString('novelScreen.chapters')}`}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<IconButton
|
||||
icon="filter-variant"
|
||||
iconColor={filter ? filterColor(theme.isDark) : theme.onSurface}
|
||||
size={24}
|
||||
onPress={() => novelBottomSheetRef.current?.present()}
|
||||
onPress={handleOpenBottomSheet}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -268,6 +453,19 @@ const styles = StyleSheet.create({
|
||||
bottomsheetContainer: {
|
||||
gap: 12,
|
||||
},
|
||||
chapterCountGradient: {
|
||||
height: 20,
|
||||
position: 'absolute',
|
||||
transform: [{ translateX: '-100%' }],
|
||||
width: '60%',
|
||||
},
|
||||
chapterCountSkeleton: {
|
||||
borderRadius: 4,
|
||||
height: 14,
|
||||
marginHorizontal: 16,
|
||||
overflow: 'hidden',
|
||||
width: 120,
|
||||
},
|
||||
chapters: {
|
||||
fontSize: 14,
|
||||
paddingHorizontal: 16,
|
||||
@@ -281,4 +479,36 @@ const styles = StyleSheet.create({
|
||||
paddingBottom: 16,
|
||||
paddingLeft: 12,
|
||||
},
|
||||
infoRow: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoSkeletonBar: {
|
||||
borderRadius: 4,
|
||||
height: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
infoSkeletonGradient: {
|
||||
height: 20,
|
||||
position: 'absolute',
|
||||
transform: [{ translateX: '-100%' }],
|
||||
width: '60%',
|
||||
},
|
||||
buttonGroupSkeletonContainer: {
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: 16,
|
||||
paddingTop: 8,
|
||||
gap: 8,
|
||||
},
|
||||
buttonSkeleton: {
|
||||
borderRadius: 8,
|
||||
flex: 1,
|
||||
height: 52,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
buttonSkeletonGradient: {
|
||||
height: 60,
|
||||
position: 'absolute',
|
||||
transform: [{ translateX: '-100%' }],
|
||||
width: '60%',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,39 +8,39 @@ import Animated, { ZoomIn } from 'react-native-reanimated';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
interface ReadButtonProps {
|
||||
chapters: ChapterInfo[];
|
||||
firstUnreadChapter?: ChapterInfo;
|
||||
lastRead?: ChapterInfo;
|
||||
navigateToChapter: (chapter: ChapterInfo) => void;
|
||||
}
|
||||
|
||||
const ReadButton = ({
|
||||
chapters,
|
||||
firstUnreadChapter,
|
||||
lastRead,
|
||||
navigateToChapter,
|
||||
}: ReadButtonProps) => {
|
||||
const { useFabForContinueReading = false } = useAppSettings();
|
||||
|
||||
const navigateToLastReadChapter = () => {
|
||||
if (lastRead) {
|
||||
navigateToChapter(lastRead);
|
||||
} else if (chapters.length) {
|
||||
navigateToChapter(chapters[0]);
|
||||
const targetChapter = lastRead ?? firstUnreadChapter;
|
||||
|
||||
const navigateToTargetChapter = () => {
|
||||
if (targetChapter) {
|
||||
navigateToChapter(targetChapter);
|
||||
}
|
||||
};
|
||||
|
||||
if (!useFabForContinueReading) {
|
||||
return chapters.length > 0 || lastRead ? (
|
||||
return targetChapter ? (
|
||||
<Animated.View entering={ZoomIn.duration(150)}>
|
||||
<Button
|
||||
title={
|
||||
lastRead
|
||||
? `${getString('novelScreen.continueReading')} ${lastRead.name}`
|
||||
: getString('novelScreen.startReadingChapters', {
|
||||
name: chapters[0].name,
|
||||
name: targetChapter.name,
|
||||
})
|
||||
}
|
||||
style={styles.margin}
|
||||
onPress={navigateToLastReadChapter}
|
||||
onPress={navigateToTargetChapter}
|
||||
mode="contained"
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
@@ -12,32 +12,42 @@ import { Portal, Text } from 'react-native-paper';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import { NovelScreenProps } from '@navigators/types';
|
||||
import {
|
||||
getNovelChaptersByNumber,
|
||||
getNovelChaptersByName,
|
||||
} from '@database/queries/ChapterQueries';
|
||||
import {
|
||||
LegendList,
|
||||
LegendListRef,
|
||||
LegendListRenderItemProps,
|
||||
} from '@legendapp/list';
|
||||
import { getNovelChapters } from '@database/queries/ChapterQueries';
|
||||
|
||||
interface JumpToChapterModalProps {
|
||||
hideModal: () => void;
|
||||
modalVisible: boolean;
|
||||
navigation: NovelScreenProps['navigation'];
|
||||
novel: NovelInfo;
|
||||
chapters: ChapterInfo[];
|
||||
chapterListRef: React.RefObject<LegendListRef | null>;
|
||||
loadUpToBatch: (batch: number) => Promise<void>;
|
||||
totalChapters?: number;
|
||||
}
|
||||
|
||||
const JumpToChapterModal = ({
|
||||
hideModal,
|
||||
modalVisible,
|
||||
chapters: loadedChapters,
|
||||
navigation,
|
||||
novel,
|
||||
chapterListRef,
|
||||
loadUpToBatch,
|
||||
totalChapters,
|
||||
}: JumpToChapterModalProps) => {
|
||||
const minNumber = 1;
|
||||
const maxNumber = totalChapters ?? -1;
|
||||
const theme = useTheme();
|
||||
const [mode, setMode] = useState(false);
|
||||
const [openChapter, setOpenChapter] = useState(false);
|
||||
const [allChapters, setAllChapters] = useState<ChapterInfo[]>([]);
|
||||
|
||||
const [text, setText] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -46,30 +56,6 @@ const JumpToChapterModal = ({
|
||||
const inputRef = useRef<RNTextInput>(null);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (modalVisible && novel?.id) {
|
||||
getNovelChapters(novel.id).then(chapters => {
|
||||
setAllChapters(chapters);
|
||||
});
|
||||
}
|
||||
}, [modalVisible, novel?.id]);
|
||||
|
||||
const minNumber = useMemo(() => {
|
||||
if (allChapters.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.min(...allChapters.map(c => c.chapterNumber || -1));
|
||||
}, [allChapters]);
|
||||
|
||||
const maxNumber = useMemo(() => {
|
||||
if (allChapters.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(...allChapters.map(c => c.chapterNumber || -1));
|
||||
}, [allChapters]);
|
||||
|
||||
const onDismiss = () => {
|
||||
hideModal();
|
||||
setText('');
|
||||
@@ -87,22 +73,28 @@ const JumpToChapterModal = ({
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToChapter = (chap: ChapterInfo) => {
|
||||
const scrollToChapter = async (chap: ChapterInfo) => {
|
||||
onDismiss();
|
||||
const index = allChapters.findIndex(c => c.id === chap.id);
|
||||
|
||||
if (index !== -1) {
|
||||
const loadedIndex = loadedChapters.findIndex(c => c.id === chap.id);
|
||||
if (loadedIndex >= 0) {
|
||||
chapterListRef.current?.scrollToIndex({
|
||||
animated: true,
|
||||
index: index,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
} else {
|
||||
chapterListRef.current?.scrollToItem({
|
||||
animated: true,
|
||||
item: chap,
|
||||
index: loadedIndex,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ((chap.position ?? -1) >= 0) {
|
||||
const targetBatch = Math.floor(chap.position! / 300);
|
||||
await loadUpToBatch(targetBatch);
|
||||
setTimeout(() => {
|
||||
chapterListRef.current?.scrollToIndex({
|
||||
animated: true,
|
||||
index: chap.position!,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,46 +128,48 @@ const JumpToChapterModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
const onSubmit = async () => {
|
||||
if (!mode) {
|
||||
// Number search
|
||||
const num = Number(text);
|
||||
if (num && num >= minNumber && num <= maxNumber) {
|
||||
if (openChapter) {
|
||||
const chapter = allChapters.find(c => c.chapterNumber === num);
|
||||
if (chapter) {
|
||||
const chapters = await getNovelChaptersByNumber(novel!.id, num);
|
||||
if (chapters.length > 0) {
|
||||
const chapter = chapters[0];
|
||||
if (openChapter) {
|
||||
return navigateToChapter(chapter);
|
||||
}
|
||||
} else {
|
||||
const chapter = allChapters.find(c => c.chapterNumber === num);
|
||||
if (chapter) {
|
||||
} else {
|
||||
return scrollToChapter(chapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return setError(
|
||||
getString('novelScreen.jumpToChapterModal.error.validChapterNumber') +
|
||||
` (${num < minNumber ? '≥ ' + minNumber : '≤ ' + maxNumber})`,
|
||||
);
|
||||
} else {
|
||||
const searchedChapters = allChapters.filter(chap =>
|
||||
chap.name.toLowerCase().includes(text?.toLowerCase()),
|
||||
// Text search
|
||||
const chapters = await getNovelChaptersByName(
|
||||
novel!.id,
|
||||
text.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!searchedChapters.length) {
|
||||
if (!chapters.length) {
|
||||
setError(
|
||||
getString('novelScreen.jumpToChapterModal.error.validChapterName'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchedChapters.length === 1) {
|
||||
if (chapters.length === 1) {
|
||||
if (openChapter) {
|
||||
return navigateToChapter(searchedChapters[0]);
|
||||
return navigateToChapter(chapters[0]);
|
||||
} else {
|
||||
return scrollToChapter(chapters[0]);
|
||||
}
|
||||
return scrollToChapter(searchedChapters[0]);
|
||||
}
|
||||
|
||||
return setResult(searchedChapters);
|
||||
return setResult(chapters);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -233,14 +227,12 @@ const JumpToChapterModal = ({
|
||||
value={openChapter}
|
||||
theme={theme}
|
||||
onPress={() => setOpenChapter(!openChapter)}
|
||||
size={20}
|
||||
/>
|
||||
<SwitchItem
|
||||
label={getString('novelScreen.jumpToChapterModal.chapterName')}
|
||||
value={mode}
|
||||
theme={theme}
|
||||
onPress={() => setMode(!mode)}
|
||||
size={20}
|
||||
/>
|
||||
</View>
|
||||
{result.length ? (
|
||||
|
||||
@@ -17,6 +17,9 @@ import { NovelInfo } from '@database/types';
|
||||
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native';
|
||||
import { MaterialDesignIconName } from '@type/icon';
|
||||
|
||||
const AnimatedAppbarAction =
|
||||
Animated.createAnimatedComponent(Appbar.Action);
|
||||
|
||||
const Menu = React.memo(
|
||||
({
|
||||
visible,
|
||||
@@ -31,20 +34,32 @@ const Menu = React.memo(
|
||||
theme: ThemeColors;
|
||||
items: { label: string; onPress: () => void }[];
|
||||
}) => {
|
||||
const contentStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.surface2 }),
|
||||
[theme.surface2],
|
||||
);
|
||||
const itemStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.surface2 }),
|
||||
[theme.surface2],
|
||||
);
|
||||
const titleStyle = useMemo(
|
||||
() => ({ color: theme.onSurface }),
|
||||
[theme.onSurface],
|
||||
);
|
||||
|
||||
return (
|
||||
<DefaultMenu
|
||||
visible={visible}
|
||||
onDismiss={onDismiss}
|
||||
anchor={anchor}
|
||||
// anchorPosition="bottom"
|
||||
contentStyle={{ backgroundColor: theme.surface2 }}
|
||||
contentStyle={contentStyle}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<DefaultMenu.Item
|
||||
key={index + item.label}
|
||||
title={item.label}
|
||||
style={{ backgroundColor: theme.surface2 }}
|
||||
titleStyle={{ color: theme.onSurface }}
|
||||
style={itemStyle}
|
||||
titleStyle={titleStyle}
|
||||
onPress={() => {
|
||||
onDismiss();
|
||||
item.onPress();
|
||||
@@ -97,6 +112,11 @@ const NovelAppbar = ({
|
||||
const [downloadMenu, showDownloadMenu] = useState(false);
|
||||
const [extraMenu, showExtraMenu] = useState(false);
|
||||
|
||||
const appbarTheme = useMemo(
|
||||
() => ({ colors: theme }),
|
||||
[theme],
|
||||
);
|
||||
|
||||
const AppbarAction = useCallback(
|
||||
(props: {
|
||||
icon: MaterialDesignIconName;
|
||||
@@ -104,20 +124,19 @@ const NovelAppbar = ({
|
||||
style?: StyleProp<ViewStyle>;
|
||||
size?: number;
|
||||
}) => {
|
||||
const AA = Animated.createAnimatedComponent(Appbar.Action);
|
||||
return (
|
||||
<AA
|
||||
<AnimatedAppbarAction
|
||||
entering={FadeIn.duration(250)}
|
||||
// delay to prevent flickering on rerenders
|
||||
exiting={FadeOut.delay(50).duration(250)}
|
||||
theme={{ colors: theme }}
|
||||
theme={appbarTheme}
|
||||
size={24}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[theme],
|
||||
[appbarTheme],
|
||||
);
|
||||
|
||||
const downloadMenuItems = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -151,8 +170,34 @@ const NovelAppbar = ({
|
||||
];
|
||||
}, [deleteChapters, downloadChapters, downloadCustomChapterModal]);
|
||||
|
||||
const extraMenuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: getString('novelScreen.edit.info'),
|
||||
onPress: () => showEditInfoModal(true),
|
||||
},
|
||||
{
|
||||
label: getString('novelScreen.edit.cover'),
|
||||
onPress: () => setCustomNovelCover(),
|
||||
},
|
||||
],
|
||||
[showEditInfoModal, setCustomNovelCover],
|
||||
);
|
||||
|
||||
const openDlMenu = useCallback(() => showDownloadMenu(true), []);
|
||||
const closeDlMenu = useCallback(() => showDownloadMenu(false), []);
|
||||
const openExtraMenu = useCallback(() => showExtraMenu(true), []);
|
||||
const closeExtraMenu = useCallback(() => showExtraMenu(false), []);
|
||||
|
||||
const openJumpToChapter = useCallback(
|
||||
() => showJumpToChapterModal(true),
|
||||
[showJumpToChapterModal],
|
||||
);
|
||||
|
||||
const headerTheme = useMemo(
|
||||
() => ({ colors: { ...theme, surface: 'transparent' } }),
|
||||
[theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
@@ -160,7 +205,7 @@ const NovelAppbar = ({
|
||||
exiting={SlideOutUp.duration(250)}
|
||||
style={headerOpacityStyle}
|
||||
>
|
||||
<Appbar.Header theme={{ colors: { ...theme, surface: 'transparent' } }}>
|
||||
<Appbar.Header theme={headerTheme}>
|
||||
<Appbar.BackAction onPress={goBack} />
|
||||
|
||||
<View style={styles.row}>
|
||||
@@ -168,18 +213,16 @@ const NovelAppbar = ({
|
||||
<AppbarAction icon="share-variant" onPress={shareNovel} />
|
||||
<AppbarAction
|
||||
icon="text-box-search-outline"
|
||||
onPress={() => {
|
||||
showJumpToChapterModal(true);
|
||||
}}
|
||||
onPress={openJumpToChapter}
|
||||
/>
|
||||
{!isLocal && (
|
||||
{!isLocal ? (
|
||||
<Menu
|
||||
theme={theme}
|
||||
visible={downloadMenu}
|
||||
onDismiss={closeDlMenu}
|
||||
anchor={
|
||||
<Appbar.Action
|
||||
theme={{ colors: theme }}
|
||||
theme={appbarTheme}
|
||||
icon="download-outline"
|
||||
onPress={openDlMenu}
|
||||
size={26}
|
||||
@@ -187,33 +230,20 @@ const NovelAppbar = ({
|
||||
}
|
||||
items={downloadMenuItems}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<Menu
|
||||
visible={extraMenu}
|
||||
onDismiss={() => showExtraMenu(false)}
|
||||
onDismiss={closeExtraMenu}
|
||||
anchor={
|
||||
<Appbar.Action
|
||||
theme={{ colors: theme }}
|
||||
theme={appbarTheme}
|
||||
icon="dots-vertical"
|
||||
onPress={() => showExtraMenu(true)}
|
||||
onPress={openExtraMenu}
|
||||
size={24}
|
||||
/>
|
||||
}
|
||||
theme={theme}
|
||||
items={[
|
||||
{
|
||||
label: getString('novelScreen.edit.info'),
|
||||
onPress: () => {
|
||||
showEditInfoModal(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: getString('novelScreen.edit.cover'),
|
||||
onPress: () => {
|
||||
setCustomNovelCover();
|
||||
},
|
||||
},
|
||||
]}
|
||||
items={extraMenuItems}
|
||||
/>
|
||||
</View>
|
||||
</Appbar.Header>
|
||||
@@ -225,7 +255,7 @@ const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
end: 0,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import ChapterItem from './ChapterItem';
|
||||
import NovelInfoHeader from './Info/NovelInfoHeader';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { pickCustomNovelCover } from '@database/queries/NovelQueries';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
import { useBoolean } from '@hooks/index';
|
||||
@@ -51,7 +51,7 @@ type NovelScreenListProps = {
|
||||
};
|
||||
};
|
||||
|
||||
const ListEmptyComponent = () => <ChapterListSkeleton />;
|
||||
const chapterKeyExtractor = (item: ChapterInfo) => 'c' + item.id;
|
||||
|
||||
const NovelScreenList = ({
|
||||
headerOpacity,
|
||||
@@ -66,6 +66,7 @@ const NovelScreenList = ({
|
||||
chapters,
|
||||
deleteChapter,
|
||||
fetching,
|
||||
firstUnreadChapter,
|
||||
getNovel,
|
||||
lastRead,
|
||||
loading,
|
||||
@@ -74,11 +75,11 @@ const NovelScreenList = ({
|
||||
setNovel,
|
||||
sortAndFilterChapters,
|
||||
setShowChapterTitles,
|
||||
updateChapter,
|
||||
novel: fetchedNovel,
|
||||
batchInformation,
|
||||
pageIndex,
|
||||
openPage,
|
||||
updateChapter,
|
||||
} = useNovelContext();
|
||||
|
||||
const { pluginId } = routeBaseNovel;
|
||||
@@ -108,7 +109,25 @@ const NovelScreenList = ({
|
||||
const theme = useTheme();
|
||||
const { top: topInset, bottom: bottomInset } = useSafeAreaInsets();
|
||||
|
||||
const { downloadQueue, downloadChapter } = useDownload();
|
||||
const { downloadingChapterIds, downloadChapter } = useDownload();
|
||||
|
||||
// Mark chapters as downloaded when their download completes
|
||||
const prevDownloadingRef = useRef(downloadingChapterIds);
|
||||
useEffect(() => {
|
||||
const prev = prevDownloadingRef.current;
|
||||
prevDownloadingRef.current = downloadingChapterIds;
|
||||
if (prev === downloadingChapterIds) {
|
||||
return;
|
||||
}
|
||||
for (const id of prev) {
|
||||
if (!downloadingChapterIds.has(id)) {
|
||||
const index = chapters.findIndex(c => c.id === id);
|
||||
if (index !== -1) {
|
||||
updateChapter(index, { isDownloaded: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [downloadingChapterIds, chapters, updateChapter]);
|
||||
|
||||
const [isFabExtended, setIsFabExtended] = useState(true);
|
||||
const [showScrollToTop, setShowScrollToTop] = useState(false);
|
||||
@@ -119,20 +138,116 @@ const NovelScreenList = ({
|
||||
|
||||
const deleteDownloadsSnackbar = useBoolean();
|
||||
|
||||
const onPageScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const y = event.nativeEvent.contentOffset.y;
|
||||
// Derive selectedIds Set for O(1) lookups
|
||||
const selectedIds = useMemo(
|
||||
() => new Set(selected.map(s => s.id)),
|
||||
[selected],
|
||||
);
|
||||
const isSelectionMode = selected.length > 0;
|
||||
|
||||
headerOpacity.set(y < 50 ? 0 : (y - 50) / 150);
|
||||
const currentScrollPosition = Math.floor(y) ?? 0;
|
||||
if (useFabForContinueReading && lastRead) {
|
||||
setIsFabExtended(currentScrollPosition <= 0);
|
||||
}
|
||||
const onPageScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const y = event.nativeEvent.contentOffset.y;
|
||||
|
||||
const screenHeight = Dimensions.get('window').height;
|
||||
setShowScrollToTop(currentScrollPosition > screenHeight / 2);
|
||||
};
|
||||
headerOpacity.set(y < 50 ? 0 : (y - 50) / 150);
|
||||
const currentScrollPosition = Math.floor(y) ?? 0;
|
||||
if (useFabForContinueReading && lastRead) {
|
||||
setIsFabExtended(currentScrollPosition <= 0);
|
||||
}
|
||||
|
||||
const onRefresh = async () => {
|
||||
const screenHeight = Dimensions.get('window').height;
|
||||
setShowScrollToTop(currentScrollPosition > screenHeight / 2);
|
||||
},
|
||||
[headerOpacity, useFabForContinueReading, lastRead],
|
||||
);
|
||||
|
||||
// --- Stable callbacks ---
|
||||
|
||||
const navigateToChapter = useCallback(
|
||||
(chapter: ChapterInfo) => {
|
||||
navigation.navigate('ReaderStack', {
|
||||
screen: 'Chapter',
|
||||
params: { novel, chapter },
|
||||
});
|
||||
},
|
||||
[navigation, novel],
|
||||
);
|
||||
|
||||
const onSelectPress = useCallback(
|
||||
(chapter: ChapterInfo) => {
|
||||
if (!isSelectionMode) {
|
||||
navigateToChapter(chapter);
|
||||
} else {
|
||||
setSelected(sel =>
|
||||
sel.some(it => it.id === chapter.id)
|
||||
? sel.filter(it => it.id !== chapter.id)
|
||||
: [...sel, chapter],
|
||||
);
|
||||
}
|
||||
},
|
||||
[isSelectionMode, navigateToChapter, setSelected],
|
||||
);
|
||||
|
||||
const onSelectLongPress = useCallback(
|
||||
(chapter: ChapterInfo) => {
|
||||
setSelected(sel => {
|
||||
if (sel.length === 0) {
|
||||
if (!disableHapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
}
|
||||
return [...sel, chapter];
|
||||
}
|
||||
if (sel.length === chapters.length) {
|
||||
return sel;
|
||||
}
|
||||
|
||||
const lastSelectedChapter = sel[sel.length - 1];
|
||||
if (lastSelectedChapter.id === chapter.id) {
|
||||
return sel;
|
||||
}
|
||||
|
||||
if (lastSelectedChapter.id > chapter.id) {
|
||||
return [
|
||||
...sel,
|
||||
chapter,
|
||||
...chapters.filter(
|
||||
(chap: ChapterInfo) =>
|
||||
(chap.id <= chapter.id || chap.id >= lastSelectedChapter.id) ===
|
||||
false,
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
...sel,
|
||||
chapter,
|
||||
...chapters.filter(
|
||||
(chap: ChapterInfo) =>
|
||||
(chap.id >= chapter.id || chap.id <= lastSelectedChapter.id) ===
|
||||
false,
|
||||
),
|
||||
];
|
||||
});
|
||||
},
|
||||
[chapters, disableHapticFeedback],
|
||||
);
|
||||
|
||||
const handleDeleteChapter = useCallback(
|
||||
(chapter: ChapterInfo) => {
|
||||
deleteChapter(chapter);
|
||||
},
|
||||
[deleteChapter],
|
||||
);
|
||||
|
||||
const handleDownloadChapter = useCallback(
|
||||
(chapter: ChapterInfo) => {
|
||||
if (novel && novel.id !== 'NO_ID') {
|
||||
downloadChapter(novel, chapter);
|
||||
}
|
||||
},
|
||||
[novel, downloadChapter],
|
||||
);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
if (novel.id !== 'NO_ID') {
|
||||
setUpdating(true);
|
||||
updateNovel(pluginId, novel.path, novel.id, {
|
||||
@@ -148,101 +263,42 @@ const NovelScreenList = ({
|
||||
.catch(error => showToast('Failed updating: ' + error.message))
|
||||
.finally(() => setUpdating(false));
|
||||
}
|
||||
};
|
||||
}, [novel, pluginId, downloadNewChapters, refreshNovelMetadata, getNovel]);
|
||||
|
||||
const onRefreshPage = async (page: string) => {
|
||||
if (novel.id !== 'NO_ID') {
|
||||
setUpdating(true);
|
||||
updateNovelPage(pluginId, novel.path, novel.id, page, {
|
||||
downloadNewChapters,
|
||||
})
|
||||
.then(() => getNovel())
|
||||
.then(() => showToast(`Updated page: ${page}`))
|
||||
.catch(e => showToast('Failed updating: ' + e.message))
|
||||
.finally(() => setUpdating(false));
|
||||
}
|
||||
};
|
||||
|
||||
const refreshControl = () => (
|
||||
<RefreshControl
|
||||
progressViewOffset={topInset + 32}
|
||||
onRefresh={onRefresh}
|
||||
refreshing={updating}
|
||||
colors={[theme.primary]}
|
||||
progressBackgroundColor={theme.onPrimary}
|
||||
/>
|
||||
const onRefreshPage = useCallback(
|
||||
async (page: string) => {
|
||||
if (novel.id !== 'NO_ID') {
|
||||
setUpdating(true);
|
||||
updateNovelPage(pluginId, novel.path, novel.id, page, {
|
||||
downloadNewChapters,
|
||||
})
|
||||
.then(() => getNovel())
|
||||
.then(() => showToast(`Updated page: ${page}`))
|
||||
.catch(e => showToast('Failed updating: ' + e.message))
|
||||
.finally(() => setUpdating(false));
|
||||
}
|
||||
},
|
||||
[novel, pluginId, downloadNewChapters, getNovel],
|
||||
);
|
||||
|
||||
const isSelected = (id: number) => {
|
||||
return selected.some(obj => obj.id === id);
|
||||
};
|
||||
const refreshControlElement = useMemo(
|
||||
() => (
|
||||
<RefreshControl
|
||||
progressViewOffset={topInset + 32}
|
||||
onRefresh={onRefresh}
|
||||
refreshing={updating}
|
||||
colors={[theme.primary]}
|
||||
progressBackgroundColor={theme.onPrimary}
|
||||
/>
|
||||
),
|
||||
[onRefresh, updating, topInset, theme.primary, theme.onPrimary],
|
||||
);
|
||||
|
||||
const onSelectPress = (chapter: ChapterInfo) => {
|
||||
if (selected.length === 0) {
|
||||
navigateToChapter(chapter);
|
||||
} else {
|
||||
if (isSelected(chapter.id)) {
|
||||
setSelected(sel => sel.filter(it => it.id !== chapter.id));
|
||||
} else {
|
||||
setSelected(sel => [...sel, chapter]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectLongPress = (chapter: ChapterInfo) => {
|
||||
if (selected.length === 0) {
|
||||
if (!disableHapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
}
|
||||
setSelected(sel => [...sel, chapter]);
|
||||
} else {
|
||||
if (selected.length === chapters.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select custom range
|
||||
*/
|
||||
const lastSelectedChapter = selected[selected.length - 1];
|
||||
|
||||
if (lastSelectedChapter.id !== chapter.id) {
|
||||
if (lastSelectedChapter.id > chapter.id) {
|
||||
setSelected(sel => [
|
||||
...sel,
|
||||
chapter,
|
||||
...chapters.filter(
|
||||
(chap: ChapterInfo) =>
|
||||
(chap.id <= chapter.id || chap.id >= lastSelectedChapter.id) ===
|
||||
false,
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
setSelected(sel => [
|
||||
...sel,
|
||||
chapter,
|
||||
...chapters.filter(
|
||||
(chap: ChapterInfo) =>
|
||||
(chap.id >= chapter.id || chap.id <= lastSelectedChapter.id) ===
|
||||
false,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToChapter = (chapter: ChapterInfo) => {
|
||||
navigation.navigate('ReaderStack', {
|
||||
screen: 'Chapter',
|
||||
params: { novel, chapter },
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
const scrollToTop = useCallback(() => {
|
||||
listRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
};
|
||||
}, [listRef]);
|
||||
|
||||
const setCustomNovelCover = async () => {
|
||||
const setCustomNovelCover = useCallback(async () => {
|
||||
if (!novel || novel.id === 'NO_ID') {
|
||||
return;
|
||||
}
|
||||
@@ -253,9 +309,9 @@ const NovelScreenList = ({
|
||||
cover: newCover,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [novel, setNovel]);
|
||||
|
||||
const saveNovelCover = async () => {
|
||||
const saveNovelCover = useCallback(async () => {
|
||||
if (!novel) {
|
||||
showToast(getString('novelScreen.coverNotSaved'));
|
||||
return;
|
||||
@@ -283,7 +339,6 @@ const NovelScreenList = ({
|
||||
? imageExtension
|
||||
: 'png';
|
||||
|
||||
// sanitize novel name as app crashes while copying file with ':' character
|
||||
const novelName = novel.name.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const fileName = `${novelName}_${novel.id}.${imageExtension}`;
|
||||
const coverDestUri = await StorageAccessFramework.createFileAsync(
|
||||
@@ -307,10 +362,124 @@ const NovelScreenList = ({
|
||||
FileManager.unlink(tempCoverUri);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [novel]);
|
||||
|
||||
const onFabPress = useCallback(() => {
|
||||
const chapter = lastRead ?? firstUnreadChapter;
|
||||
if (chapter) {
|
||||
navigation.navigate('ReaderStack', {
|
||||
screen: 'Chapter',
|
||||
params: { novel, chapter },
|
||||
});
|
||||
}
|
||||
}, [lastRead, firstUnreadChapter, novel, navigation]);
|
||||
|
||||
const hasMultiplePages = pages.length > 1 || (novel?.totalPages ?? 0) > 1;
|
||||
|
||||
const openPageNavDrawer = useCallback(
|
||||
() => pageNavigationSheetRef.current?.present(),
|
||||
[],
|
||||
);
|
||||
|
||||
// --- Memoized list components ---
|
||||
|
||||
const paginationControl = useMemo(() => {
|
||||
if (!hasMultiplePages) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View>
|
||||
<PagePaginationControl
|
||||
pages={pages}
|
||||
currentPageIndex={pageIndex}
|
||||
onPageChange={openPage}
|
||||
onOpenDrawer={openPageNavDrawer}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [hasMultiplePages, pages, pageIndex, openPage, openPageNavDrawer, theme]);
|
||||
|
||||
const listEmptyComponent = useMemo(
|
||||
() => (fetching ? <ChapterListSkeleton /> : null),
|
||||
[fetching],
|
||||
);
|
||||
|
||||
const listHeader = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<NovelInfoHeader
|
||||
chapters={chapters}
|
||||
deleteDownloadsSnackbar={deleteDownloadsSnackbar}
|
||||
fetching={fetching}
|
||||
filter={filter}
|
||||
firstUnreadChapter={firstUnreadChapter}
|
||||
isLoading={loading}
|
||||
lastRead={lastRead}
|
||||
navigateToChapter={navigateToChapter}
|
||||
navigation={navigation}
|
||||
novel={novel}
|
||||
novelBottomSheetRef={novelBottomSheetRef}
|
||||
onRefreshPage={onRefreshPage}
|
||||
page={pages.length > 1 ? pages[pageIndex] : undefined}
|
||||
pageIndex={pageIndex}
|
||||
pages={pages}
|
||||
pageNavigationSheetRef={pageNavigationSheetRef}
|
||||
setCustomNovelCover={setCustomNovelCover}
|
||||
saveNovelCover={saveNovelCover}
|
||||
theme={theme}
|
||||
totalChapters={batchInformation.totalChapters}
|
||||
trackerSheetRef={trackerSheetRef}
|
||||
/>
|
||||
{paginationControl}
|
||||
</>
|
||||
),
|
||||
[
|
||||
chapters,
|
||||
deleteDownloadsSnackbar,
|
||||
fetching,
|
||||
filter,
|
||||
firstUnreadChapter,
|
||||
loading,
|
||||
lastRead,
|
||||
navigateToChapter,
|
||||
navigation,
|
||||
novel,
|
||||
onRefreshPage,
|
||||
pages,
|
||||
pageIndex,
|
||||
setCustomNovelCover,
|
||||
saveNovelCover,
|
||||
theme,
|
||||
batchInformation.totalChapters,
|
||||
paginationControl,
|
||||
],
|
||||
);
|
||||
|
||||
const scrollToTopFabStyle = useMemo(
|
||||
() => [
|
||||
styles.scrollToTopFab,
|
||||
{ backgroundColor: theme.surface2, marginBottom: bottomInset },
|
||||
],
|
||||
[theme.surface2, bottomInset],
|
||||
);
|
||||
|
||||
const continueFabStyle = useMemo(
|
||||
() => [
|
||||
styles.fab,
|
||||
{ backgroundColor: theme.primary, marginBottom: bottomInset },
|
||||
],
|
||||
[theme.primary, bottomInset],
|
||||
);
|
||||
|
||||
const continueFabLabel = useMemo(
|
||||
() =>
|
||||
lastRead
|
||||
? getString('common.resume')
|
||||
: getString('novelScreen.startReadingChapters', { name: '' }).trim(),
|
||||
[lastRead],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LegendList
|
||||
@@ -318,91 +487,40 @@ const NovelScreenList = ({
|
||||
estimatedItemSize={64}
|
||||
data={chapters}
|
||||
recycleItems
|
||||
extraData={[
|
||||
chapters.length,
|
||||
selected.length,
|
||||
novel.id,
|
||||
loading,
|
||||
downloadQueue.length,
|
||||
]}
|
||||
// ListEmptyComponent={ListEmptyComponent}
|
||||
ListFooterComponent={
|
||||
!fetching ? (
|
||||
hasMultiplePages ? (
|
||||
<View>
|
||||
<PagePaginationControl
|
||||
pages={pages}
|
||||
currentPageIndex={pageIndex}
|
||||
onPageChange={openPage}
|
||||
onOpenDrawer={() => pageNavigationSheetRef.current?.present()}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
) : undefined
|
||||
) : (
|
||||
ListEmptyComponent
|
||||
)
|
||||
}
|
||||
renderItem={({ item, index }) => {
|
||||
ListEmptyComponent={listEmptyComponent}
|
||||
renderItem={({ item }) => {
|
||||
if (novel.id === 'NO_ID') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ChapterItem
|
||||
isDownloading={downloadQueue.some(
|
||||
c => c.task.data.chapterId === item.id,
|
||||
)}
|
||||
chapter={item}
|
||||
isDownloading={downloadingChapterIds.has(item.id)}
|
||||
isBookmarked={!!item.bookmark}
|
||||
isLocal={novel.isLocal}
|
||||
theme={theme}
|
||||
chapter={item}
|
||||
showChapterTitles={showChapterTitles}
|
||||
deleteChapter={() => deleteChapter(item)}
|
||||
downloadChapter={() => downloadChapter(novel, item)}
|
||||
isSelected={isSelected(item.id)}
|
||||
isSelected={selectedIds.has(item.id)}
|
||||
novelName={novel.name}
|
||||
onDeleteChapter={handleDeleteChapter}
|
||||
onDownloadChapter={handleDownloadChapter}
|
||||
onSelectPress={onSelectPress}
|
||||
onSelectLongPress={onSelectLongPress}
|
||||
navigateToChapter={navigateToChapter}
|
||||
novelName={novel.name}
|
||||
setChapterDownloaded={(value: boolean) =>
|
||||
updateChapter?.(index, { isDownloaded: value })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
keyExtractor={item => 'c' + item.id}
|
||||
keyExtractor={chapterKeyExtractor}
|
||||
extraData={downloadingChapterIds}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
refreshControl={refreshControl()}
|
||||
refreshControl={refreshControlElement}
|
||||
onEndReached={getNextChapterBatch}
|
||||
onEndReachedThreshold={6}
|
||||
onScroll={onPageScroll}
|
||||
scrollEventThrottle={16}
|
||||
drawDistance={1000}
|
||||
ListHeaderComponent={
|
||||
<NovelInfoHeader
|
||||
chapters={chapters}
|
||||
deleteDownloadsSnackbar={deleteDownloadsSnackbar}
|
||||
fetching={fetching}
|
||||
filter={filter}
|
||||
isLoading={loading}
|
||||
lastRead={lastRead}
|
||||
navigateToChapter={navigateToChapter}
|
||||
navigation={navigation}
|
||||
novel={novel}
|
||||
novelBottomSheetRef={novelBottomSheetRef}
|
||||
onRefreshPage={onRefreshPage}
|
||||
page={pages.length > 1 ? pages[pageIndex] : undefined}
|
||||
pageIndex={pageIndex}
|
||||
pages={pages}
|
||||
pageNavigationSheetRef={pageNavigationSheetRef}
|
||||
setCustomNovelCover={setCustomNovelCover}
|
||||
saveNovelCover={saveNovelCover}
|
||||
theme={theme}
|
||||
totalChapters={batchInformation.totalChapters}
|
||||
trackerSheetRef={trackerSheetRef}
|
||||
/>
|
||||
}
|
||||
ListHeaderComponent={listHeader}
|
||||
/>
|
||||
{novel.id !== 'NO_ID' && (
|
||||
{novel.id !== 'NO_ID' ? (
|
||||
<>
|
||||
<NovelBottomSheet
|
||||
bottomSheetRef={novelBottomSheetRef}
|
||||
@@ -423,12 +541,9 @@ const NovelScreenList = ({
|
||||
openPage={openPage}
|
||||
/>
|
||||
) : null}
|
||||
{showScrollToTop && (
|
||||
{showScrollToTop ? (
|
||||
<AnimatedFAB
|
||||
style={[
|
||||
styles.scrollToTopFab,
|
||||
{ backgroundColor: theme.surface2, marginBottom: bottomInset },
|
||||
]}
|
||||
style={scrollToTopFabStyle}
|
||||
color={theme.primary}
|
||||
icon="arrow-up"
|
||||
label=""
|
||||
@@ -436,40 +551,24 @@ const NovelScreenList = ({
|
||||
onPress={scrollToTop}
|
||||
visible={showScrollToTop}
|
||||
/>
|
||||
)}
|
||||
{useFabForContinueReading && (lastRead || chapters[0]) ? (
|
||||
) : null}
|
||||
{useFabForContinueReading && (lastRead || firstUnreadChapter) ? (
|
||||
<AnimatedFAB
|
||||
style={[
|
||||
styles.fab,
|
||||
{ backgroundColor: theme.primary, marginBottom: bottomInset },
|
||||
]}
|
||||
style={continueFabStyle}
|
||||
extended={isFabExtended && !loading}
|
||||
color={theme.onPrimary}
|
||||
uppercase={false}
|
||||
label={
|
||||
lastRead
|
||||
? getString('common.resume')
|
||||
: getString('novelScreen.startReadingChapters', {
|
||||
name: '',
|
||||
}).trim()
|
||||
}
|
||||
label={continueFabLabel}
|
||||
icon="play"
|
||||
onPress={() => {
|
||||
navigation.navigate('ReaderStack', {
|
||||
screen: 'Chapter',
|
||||
params: {
|
||||
novel: novel,
|
||||
chapter: lastRead ?? chapters[0],
|
||||
},
|
||||
});
|
||||
}}
|
||||
onPress={onFabPress}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
contentContainer: { paddingBottom: 100 },
|
||||
|
||||
@@ -34,12 +34,10 @@ export default function PageNavigationBottomSheet({
|
||||
<View
|
||||
style={[
|
||||
styles.pageItemContainer,
|
||||
{
|
||||
backgroundColor: isSelected
|
||||
? theme.isDark
|
||||
? color(theme.primary).alpha(0.2).string()
|
||||
: color(theme.primaryContainer).alpha(0.5).string()
|
||||
: 'transparent',
|
||||
isSelected && {
|
||||
backgroundColor: theme.isDark
|
||||
? color(theme.primary).alpha(0.2).string()
|
||||
: color(theme.primaryContainer).alpha(0.5).string(),
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -83,8 +81,8 @@ export default function PageNavigationBottomSheet({
|
||||
styles.contentContainer,
|
||||
{
|
||||
backgroundColor: overlay(2, theme.surface),
|
||||
marginLeft: left,
|
||||
marginRight: right,
|
||||
marginStart: left,
|
||||
marginEnd: right,
|
||||
paddingBottom: insets.bottom,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -31,24 +31,29 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
|
||||
indices.push(i);
|
||||
}
|
||||
} else {
|
||||
// Always show first page if not current
|
||||
if (currentPageIndex !== 0) {
|
||||
indices.push(0);
|
||||
}
|
||||
|
||||
// Show page before current (with ellipsis if there's a gap)
|
||||
const leftPageIndex = currentPageIndex - 1;
|
||||
if (leftPageIndex > 0) {
|
||||
if (leftPageIndex > 1) {
|
||||
indices.push('ellipsis');
|
||||
}
|
||||
indices.push(leftPageIndex);
|
||||
}
|
||||
|
||||
// Always show current page
|
||||
indices.push(currentPageIndex);
|
||||
|
||||
if (currentPageIndex < totalPages - 1) {
|
||||
// Show ellipsis after current only if there's a gap to last page
|
||||
const rightPageIndex = currentPageIndex + 1;
|
||||
if (rightPageIndex < totalPages - 1) {
|
||||
indices.push('ellipsis');
|
||||
}
|
||||
|
||||
// Always show last page if not current
|
||||
if (currentPageIndex !== totalPages - 1) {
|
||||
indices.push(totalPages - 1);
|
||||
}
|
||||
@@ -134,9 +139,9 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
borderColor: isActive ? 'transparent' : borderColor,
|
||||
backgroundColor: isActive ? theme.primary : theme.surface,
|
||||
},
|
||||
isActive ? styles.activeButton : styles.inactiveButton,
|
||||
]}
|
||||
onPress={() => handlePagePress(pageIndex)}
|
||||
android_ripple={{
|
||||
@@ -150,8 +155,8 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
|
||||
styles.pageText,
|
||||
{
|
||||
color: isActive ? theme.onPrimary : theme.onSurface,
|
||||
fontWeight: isActive ? '600' : '400',
|
||||
},
|
||||
isActive ? styles.activePageText : styles.inactivePageText,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@@ -198,13 +203,12 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
@@ -224,13 +228,28 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
pageNumbersRow: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-evenly',
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
flexShrink: 1,
|
||||
gap: 8,
|
||||
},
|
||||
pageText: {
|
||||
fontSize: 15,
|
||||
letterSpacing: 0.15,
|
||||
maxWidth: 100,
|
||||
maxWidth: 80,
|
||||
},
|
||||
activeButton: {
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
inactiveButton: {
|
||||
borderColor: borderColor,
|
||||
},
|
||||
activePageText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
inactivePageText: {
|
||||
fontWeight: '400',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { FlatList, StyleSheet, Text, View } from 'react-native';
|
||||
import { Dimensions, FlatList, StyleSheet, Text, View } from 'react-native';
|
||||
import { Divider, Portal } from 'react-native-paper';
|
||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||
|
||||
@@ -61,6 +61,7 @@ const SetCategoryModal: React.FC<SetCategoryModalProps> = ({
|
||||
</Text>
|
||||
<FlatList
|
||||
data={categories}
|
||||
style={styles.categoryList}
|
||||
renderItem={({ item }) => (
|
||||
<Checkbox
|
||||
status={
|
||||
@@ -127,6 +128,9 @@ const SetCategoryModal: React.FC<SetCategoryModalProps> = ({
|
||||
export default SetCategoryModal;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
categoryList: {
|
||||
maxHeight: Dimensions.get('window').height * 0.4,
|
||||
},
|
||||
divider: { height: 1, width: '90%', marginLeft: '5%' },
|
||||
btnContainer: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RadioButton, RadioButtonGroup } from '@components/RadioButton';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import {
|
||||
getAniListScoreFormatting,
|
||||
getKitsuScoreFormatting,
|
||||
getMyAnimeListScoreLabel,
|
||||
} from './constants';
|
||||
import {
|
||||
@@ -141,6 +142,41 @@ export const AniListScoreSelector: React.FC<AniListScoreSelectorProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Kitsu uses a 0.5-10 scale (displayed as half-increments).
|
||||
* Score values: 0 (no score), 1 (0.5), 2 (1.0), 3 (1.5), ... 20 (10.0)
|
||||
*/
|
||||
export const KitsuScoreSelector: React.FC<ScoreSelectorProps> = ({
|
||||
trackItem,
|
||||
onUpdateScore,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const formatting = getKitsuScoreFormatting();
|
||||
const scores = Array.from({ length: formatting.count }, (_, i) => i);
|
||||
|
||||
const handleValueChange = (value: string) => {
|
||||
onUpdateScore(Number(value));
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<RadioButtonGroup
|
||||
onValueChange={handleValueChange}
|
||||
value={trackItem.score}
|
||||
>
|
||||
{scores.map(score => (
|
||||
<RadioButton
|
||||
key={score}
|
||||
value={score}
|
||||
label={formatting.label(score)}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</RadioButtonGroup>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button, DialogTitle, Modal } from '@components';
|
||||
import { getString } from '@strings/translations';
|
||||
import {
|
||||
AniListScoreSelector,
|
||||
KitsuScoreSelector,
|
||||
MangaUpdatesScoreSelector,
|
||||
MyAnimeListScoreSelector,
|
||||
} from './ScoreSelectors';
|
||||
@@ -46,6 +47,13 @@ const SetTrackScoreDialog: React.FC<TrackScoreDialogProps> = ({
|
||||
onUpdateScore={setSelectedScore}
|
||||
/>
|
||||
);
|
||||
case 'Kitsu':
|
||||
return (
|
||||
<KitsuScoreSelector
|
||||
trackItem={{ ...trackItem, score: selectedScore }}
|
||||
onUpdateScore={setSelectedScore}
|
||||
/>
|
||||
);
|
||||
case 'AniList':
|
||||
default:
|
||||
return (
|
||||
|
||||
@@ -108,7 +108,7 @@ const TrackSearchDialog: React.FC<TrackSearchDialogProps> = ({
|
||||
</TouchableRipple>
|
||||
);
|
||||
},
|
||||
[selectedNovel, handleSelectNovel],
|
||||
[selectedNovel, handleSelectNovel, theme.rippleColor, theme.primary, theme.onSurface],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -45,11 +45,14 @@ const TrackSheet: React.FC<TrackSheetProps> = ({ bottomSheetRef, novel }) => {
|
||||
bottomSheetRef.current?.close();
|
||||
}, [bottomSheetRef]);
|
||||
|
||||
const handleSetSearchTrackDialog = useCallback((tracker: TrackerMetadata) => {
|
||||
closeBottomSheet();
|
||||
setActiveTracker(tracker);
|
||||
setTrackSearchDialog(true);
|
||||
}, []);
|
||||
const handleSetSearchTrackDialog = useCallback(
|
||||
(tracker: TrackerMetadata) => {
|
||||
closeBottomSheet();
|
||||
setActiveTracker(tracker);
|
||||
setTrackSearchDialog(true);
|
||||
},
|
||||
[closeBottomSheet],
|
||||
);
|
||||
|
||||
const handleSetStatusDialog = useCallback(
|
||||
(tracker: TrackerMetadata) => {
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { IconButton } from 'react-native-paper';
|
||||
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { getAniListScoreFormatting } from './constants';
|
||||
import {
|
||||
getAniListScoreFormatting,
|
||||
getKitsuScoreFormatting,
|
||||
} from './constants';
|
||||
import { AddTrackingCardProps, TrackedItemCardProps } from './types';
|
||||
|
||||
export const AddTrackingCard: React.FC<AddTrackingCardProps> = ({
|
||||
@@ -60,12 +63,16 @@ export const TrackedItemCard: React.FC<TrackedItemCardProps> = ({
|
||||
}
|
||||
|
||||
if (tracker.name === 'MangaUpdates') {
|
||||
// Show decimal for MangaUpdates if it has decimal places
|
||||
/* Show decimal for MangaUpdates if it has decimal places */
|
||||
return Number.isInteger(trackItem.score)
|
||||
? trackItem.score.toString()
|
||||
: trackItem.score.toFixed(1);
|
||||
}
|
||||
|
||||
if (tracker.name === 'Kitsu') {
|
||||
return getKitsuScoreFormatting().label(trackItem.score);
|
||||
}
|
||||
|
||||
return trackItem.score;
|
||||
}, [tracker, trackItem.score]);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export const TRACKER_ICONS: Record<TrackerName, ImageSourcePropType> = {
|
||||
MyAnimeList: require('../../../../../assets/mal.png'),
|
||||
MangaUpdates: require('../../../../../assets/mangaupdates.png'),
|
||||
AniList: require('../../../../../assets/anilist.png'),
|
||||
Kitsu: require('../../../../../assets/kitsu.png'),
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<UserListStatus, string> = {
|
||||
@@ -91,3 +92,25 @@ export const getAniListScoreFormatting = (
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Kitsu uses a 0.5-10 scale (displayed as half-increments).
|
||||
* Internally stored as ratingTwenty (2-20), displayed as ratingTwenty/2.
|
||||
* This provides 21 options: 0 (no score), 0.5, 1.0, 1.5, ... 10.0
|
||||
*/
|
||||
export const getKitsuScoreFormatting = (): ScoreFormatting => {
|
||||
return {
|
||||
count: 21,
|
||||
label: score => {
|
||||
if (score === 0) {
|
||||
return '-';
|
||||
}
|
||||
/* Convert index to Kitsu's half-point scale (0.5-10) */
|
||||
const displayScore = score / 2;
|
||||
return displayScore.toLocaleString(undefined, {
|
||||
minimumFractionDigits: displayScore % 1 === 0 ? 0 : 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -122,7 +122,7 @@ export const ChapterContent = ({
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={[{ paddingLeft: left, paddingRight: right }, styles.container]}
|
||||
style={[{ paddingStart: left, paddingEnd: right }, styles.container]}
|
||||
>
|
||||
{keepScreenOn ? <KeepScreenAwake /> : null}
|
||||
{loading ? (
|
||||
|
||||
@@ -16,6 +16,7 @@ import renderListChapter from './RenderListChapter';
|
||||
import { useChapterContext } from '@screens/reader/ChapterContext';
|
||||
import { useNovelContext } from '@screens/novel/NovelContext';
|
||||
import { LegendList, LegendListRef, ViewToken } from '@legendapp/list';
|
||||
import { noop } from 'lodash-es';
|
||||
|
||||
type ButtonProperties = {
|
||||
text: string;
|
||||
@@ -29,7 +30,15 @@ type ButtonsProperties = {
|
||||
|
||||
const ChapterDrawer = () => {
|
||||
const { chapter, getChapter, setLoading } = useChapterContext();
|
||||
const { chapters, novelSettings, pages, setPageIndex } = useNovelContext();
|
||||
const {
|
||||
chapters,
|
||||
novelSettings,
|
||||
pages,
|
||||
fetching,
|
||||
batchInformation,
|
||||
getNextChapterBatch,
|
||||
setPageIndex,
|
||||
} = useNovelContext();
|
||||
const theme = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { defaultChapterSort } = useAppSettings();
|
||||
@@ -89,9 +98,9 @@ const ChapterDrawer = () => {
|
||||
const newBtnLayout = Object.create(defaultButtonLayout);
|
||||
|
||||
if (viewableItems.length === 0) return;
|
||||
const cKey = (scrollToIndex.current ?? 0) + 2;
|
||||
const vKey = parseInt(viewableItems[0].key, 10);
|
||||
const visible = vKey <= cKey && cKey <= vKey + viewableItems.length - 1;
|
||||
const visible = viewableItems
|
||||
.map(v => v.index)
|
||||
.includes((scrollToIndex.current ?? 0) + 2);
|
||||
|
||||
if (!visible && scrollToIndex.current !== undefined) {
|
||||
if (
|
||||
@@ -110,12 +119,7 @@ const ChapterDrawer = () => {
|
||||
};
|
||||
}
|
||||
}
|
||||
if (cKey <= 2 && vKey <= 4) {
|
||||
newBtnLayout.up = {
|
||||
text: curChapter,
|
||||
index: scrollToIndex.current,
|
||||
};
|
||||
}
|
||||
|
||||
setButtonProperties(newBtnLayout);
|
||||
},
|
||||
[defaultButtonLayout, listAscending],
|
||||
@@ -136,7 +140,10 @@ const ChapterDrawer = () => {
|
||||
useEffect(() => {
|
||||
const next = calculateScrollToIndex();
|
||||
if (next !== undefined) {
|
||||
if (scrollToIndex.current === undefined) {
|
||||
if (
|
||||
scrollToIndex.current === undefined ||
|
||||
next !== scrollToIndex.current
|
||||
) {
|
||||
scroll(next);
|
||||
}
|
||||
scrollToIndex.current = next;
|
||||
@@ -154,7 +161,7 @@ const ChapterDrawer = () => {
|
||||
recycleItems
|
||||
viewabilityConfig={{
|
||||
minimumViewTime: 100,
|
||||
viewAreaCoveragePercentThreshold: 95,
|
||||
itemVisiblePercentThreshold: 90,
|
||||
}}
|
||||
onViewableItemsChanged={checkViewableItems}
|
||||
data={chapters}
|
||||
@@ -176,6 +183,12 @@ const ChapterDrawer = () => {
|
||||
}
|
||||
estimatedItemSize={60}
|
||||
initialScrollIndex={scrollToIndex.current}
|
||||
onEndReached={
|
||||
batchInformation.batch < batchInformation.total && !fetching
|
||||
? getNextChapterBatch
|
||||
: noop
|
||||
}
|
||||
onEndReachedThreshold={6}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.footer}>
|
||||
@@ -222,8 +235,7 @@ const createStylesheet = (theme: ThemeColors, insets: EdgeInsets) => {
|
||||
drawerElementContainer: {
|
||||
borderRadius: 50,
|
||||
margin: 4,
|
||||
marginLeft: 16,
|
||||
marginRight: 16,
|
||||
marginHorizontal: 16,
|
||||
minHeight: 48,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -128,7 +128,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
},
|
||||
bookmark: {
|
||||
marginRight: 4,
|
||||
marginEnd: 4,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
|
||||
@@ -36,7 +36,7 @@ const ReaderFontPicker = () => {
|
||||
/>
|
||||
);
|
||||
},
|
||||
[isSelected, setChapterReaderSettings],
|
||||
[isSelected, setChapterReaderSettings, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -65,6 +65,6 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 6,
|
||||
},
|
||||
title: {
|
||||
marginRight: 16,
|
||||
marginEnd: 16,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ const ReaderSheetPreferenceItem: React.FC<ReaderSheetPreferenceItemProps> = ({
|
||||
onPress,
|
||||
theme,
|
||||
}) => {
|
||||
const size = 20;
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.container}
|
||||
@@ -30,14 +29,14 @@ const ReaderSheetPreferenceItem: React.FC<ReaderSheetPreferenceItemProps> = ({
|
||||
fallback={
|
||||
<View
|
||||
style={{
|
||||
width: size * 2 + size / 6,
|
||||
height: size + size / 3,
|
||||
borderRadius: size,
|
||||
width: 52,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Switch value={value} onValueChange={onPress} size={size} />
|
||||
<Switch value={value} onValueChange={onPress} />
|
||||
</Suspense>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
@@ -22,18 +22,18 @@ interface VoicePickerModalProps {
|
||||
currentVoice?: Voice;
|
||||
}
|
||||
|
||||
const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
|
||||
visible,
|
||||
onDismiss,
|
||||
voices,
|
||||
onSelect,
|
||||
currentVoice
|
||||
const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
|
||||
visible,
|
||||
onDismiss,
|
||||
voices,
|
||||
onSelect,
|
||||
currentVoice
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [selectedLanguages, setSelectedLanguages] = useState<string[]>([]);
|
||||
// Get system language safely using getLocales()
|
||||
const systemLocale = getLocales()[0]?.languageCode || 'en';
|
||||
|
||||
|
||||
// Get unique languages from voices
|
||||
const availableLanguages = useMemo(() => {
|
||||
const languages = new Set<string>();
|
||||
@@ -61,7 +61,7 @@ const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
|
||||
return lang === systemLocale;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return voices.filter(voice => {
|
||||
if (voice.name === 'System') return true;
|
||||
const lang = voice.language?.split('-')[0];
|
||||
@@ -99,14 +99,14 @@ const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
|
||||
<Text style={[styles.modalTitle, { color: theme.onSurface }]}>
|
||||
Select Voice
|
||||
</Text>
|
||||
|
||||
|
||||
{/* Language Filter */}
|
||||
<View style={styles.languageFilterContainer}>
|
||||
<Text style={[styles.filterLabel, { color: theme.onSurfaceVariant }]}>
|
||||
Filter by language:
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.languageChipsScroll}
|
||||
>
|
||||
@@ -115,7 +115,7 @@ const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
|
||||
const isSystemLang = lang === systemLocale;
|
||||
const showingSystemOnly = selectedLanguages.length === 0;
|
||||
const isActive = isSelected || (showingSystemOnly && isSystemLang);
|
||||
|
||||
|
||||
return (
|
||||
<Chip
|
||||
key={lang}
|
||||
@@ -125,10 +125,10 @@ const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
|
||||
styles.languageChip,
|
||||
isActive && { backgroundColor: theme.primary }
|
||||
]}
|
||||
textStyle={{
|
||||
color: isActive ? theme.onPrimary : theme.onSurface,
|
||||
fontSize: 12
|
||||
}}
|
||||
textStyle={[
|
||||
styles.languageChipText,
|
||||
{ color: isActive ? theme.onPrimary : theme.onSurface }
|
||||
]}
|
||||
>
|
||||
{lang.toUpperCase()}
|
||||
{isSystemLang && ' (System)'}
|
||||
@@ -170,7 +170,7 @@ const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
|
||||
)}
|
||||
</View>
|
||||
{currentVoice?.identifier === voice.identifier && (
|
||||
<Text style={{ color: theme.primary, fontSize: 16 }}>✓</Text>
|
||||
<Text style={[styles.checkIcon, { color: theme.primary }]}>✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))
|
||||
@@ -194,7 +194,7 @@ const TTSTab: React.FC = () => {
|
||||
TTSEnable = true,
|
||||
setChapterGeneralSettings,
|
||||
} = useChapterGeneralSettings();
|
||||
|
||||
|
||||
const { tts, setChapterReaderSettings } = useChapterReaderSettings();
|
||||
const [voices, setVoices] = useState<Voice[]>([]);
|
||||
const [voiceModalVisible, setVoiceModalVisible] = useState(false);
|
||||
@@ -219,7 +219,7 @@ const TTSTab: React.FC = () => {
|
||||
>
|
||||
<View style={styles.section}>
|
||||
<List.SubHeader theme={theme}>Text to Speech</List.SubHeader>
|
||||
|
||||
|
||||
<ReaderSheetPreferenceItem
|
||||
label="Enable TTS"
|
||||
value={TTSEnable}
|
||||
@@ -228,7 +228,7 @@ const TTSTab: React.FC = () => {
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
|
||||
|
||||
{TTSEnable && (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
@@ -407,7 +407,7 @@ const styles = StyleSheet.create({
|
||||
flexGrow: 0,
|
||||
},
|
||||
languageChip: {
|
||||
marginRight: 8,
|
||||
marginEnd: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
voiceList: {
|
||||
@@ -440,4 +440,10 @@ const styles = StyleSheet.create({
|
||||
cancelButton: {
|
||||
marginTop: 16,
|
||||
},
|
||||
languageChipText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
checkIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -92,12 +92,12 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
useEffect(() => {
|
||||
setReaderSettings(
|
||||
getMMKVObject<ChapterReaderSettings>(CHAPTER_READER_SETTINGS) ||
|
||||
initialChapterReaderSettings,
|
||||
initialChapterReaderSettings,
|
||||
);
|
||||
}, [chapter.id]);
|
||||
|
||||
// Update battery level when chapter changes to ensure fresh value on navigation
|
||||
const batteryLevel = useMemo(() => getBatteryLevelSync(), [chapter.id]);
|
||||
const batteryLevel = useMemo(() => getBatteryLevelSync(), []);
|
||||
const plugin = getPlugin(novel?.pluginId);
|
||||
const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`;
|
||||
const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`;
|
||||
@@ -286,6 +286,9 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
rate: readerSettingsRef.current.tts?.rate || 1,
|
||||
});
|
||||
};
|
||||
const isRTL = plugin?.lang === 'Arabic' || plugin?.lang === 'Hebrew';
|
||||
const readerDir = isRTL ? 'rtl' : 'ltr';
|
||||
|
||||
return (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
@@ -334,9 +337,9 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
| undefined;
|
||||
const queue = Array.isArray(payload?.queue)
|
||||
? payload?.queue.filter(
|
||||
(item): item is string =>
|
||||
typeof item === 'string' && item.trim().length > 0,
|
||||
)
|
||||
(item): item is string =>
|
||||
typeof item === 'string' && item.trim().length > 0,
|
||||
)
|
||||
: [];
|
||||
ttsQueueRef.current = queue;
|
||||
if (typeof payload?.startIndex === 'number') {
|
||||
@@ -416,7 +419,7 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
body: plugin?.imageRequestInit?.body,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html dir="${readerDir}">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
|
||||
@@ -441,8 +444,8 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
--theme-onSecondary: ${theme.onSecondary};
|
||||
--theme-surface: ${theme.surface};
|
||||
--theme-surface-0-9: ${color(theme.surface)
|
||||
.alpha(0.9)
|
||||
.toString()};
|
||||
.alpha(0.9)
|
||||
.toString()};
|
||||
--theme-onSurface: ${theme.onSurface};
|
||||
--theme-surfaceVariant: ${theme.surfaceVariant};
|
||||
--theme-onSurfaceVariant: ${theme.onSurfaceVariant};
|
||||
@@ -452,23 +455,20 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
|
||||
@font-face {
|
||||
font-family: ${readerSettings.fontFamily};
|
||||
src: url("file:///android_asset/fonts/${
|
||||
readerSettings.fontFamily
|
||||
}.ttf");
|
||||
src: url("file:///android_asset/fonts/${readerSettings.fontFamily
|
||||
}.ttf");
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<link rel="stylesheet" href="${pluginCustomCSS}">
|
||||
<style>${readerSettings.customCSS}</style>
|
||||
</head>
|
||||
<body class="${
|
||||
chapterGeneralSettings.pageReader ? 'page-reader' : ''
|
||||
}">
|
||||
<div class="transition-chapter" style="transform: ${
|
||||
nextChapterScreenVisible.current
|
||||
? 'translateX(-100%)'
|
||||
: 'translateX(0%)'
|
||||
};
|
||||
<body class="${chapterGeneralSettings.pageReader ? 'page-reader' : ''
|
||||
}">
|
||||
<div class="transition-chapter" style="transform: ${nextChapterScreenVisible.current
|
||||
? 'translateX(-100%)'
|
||||
: 'translateX(0%)'
|
||||
};
|
||||
${chapterGeneralSettings.pageReader ? '' : 'display: none'}"
|
||||
">${chapter.name}</div>
|
||||
<div id="LNReader-chapter">
|
||||
@@ -478,30 +478,28 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
</body>
|
||||
<script>
|
||||
var initialPageReaderConfig = ${JSON.stringify({
|
||||
nextChapterScreenVisible: nextChapterScreenVisible.current,
|
||||
})};
|
||||
|
||||
|
||||
nextChapterScreenVisible: nextChapterScreenVisible.current,
|
||||
})};
|
||||
|
||||
|
||||
var initialReaderConfig = ${JSON.stringify({
|
||||
readerSettings,
|
||||
chapterGeneralSettings,
|
||||
novel,
|
||||
chapter,
|
||||
nextChapter,
|
||||
prevChapter,
|
||||
batteryLevel,
|
||||
autoSaveInterval: 2222,
|
||||
DEBUG: __DEV__,
|
||||
strings: {
|
||||
finished: `${getString(
|
||||
'readerScreen.finished',
|
||||
)}: ${chapter.name.trim()}`,
|
||||
nextChapter: getString('readerScreen.nextChapter', {
|
||||
name: nextChapter?.name,
|
||||
}),
|
||||
noNextChapter: getString('readerScreen.noNextChapter'),
|
||||
},
|
||||
})}
|
||||
readerSettings,
|
||||
chapterGeneralSettings,
|
||||
novel,
|
||||
chapter,
|
||||
nextChapter,
|
||||
prevChapter,
|
||||
batteryLevel,
|
||||
autoSaveInterval: 2222,
|
||||
DEBUG: __DEV__,
|
||||
strings: {
|
||||
finished: getString('readerScreen.finished') + ': ' + chapter.name.trim(),
|
||||
nextChapter: getString('readerScreen.nextChapter', {
|
||||
name: nextChapter?.name,
|
||||
}),
|
||||
noNextChapter: getString('readerScreen.noNextChapter'),
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
<script src="${assetsUriPrefix}/js/polyfill-onscrollend.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/icons.js"></script>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
getChapter as getDbChapter,
|
||||
getChapterCount,
|
||||
getNextChapter,
|
||||
getPrevChapter,
|
||||
insertChapters,
|
||||
} from '@database/queries/ChapterQueries';
|
||||
import { insertHistory } from '@database/queries/HistoryQueries';
|
||||
import { ChapterInfo, NovelInfo } from '@database/types';
|
||||
@@ -11,7 +13,7 @@ import {
|
||||
useTrackedNovel,
|
||||
useTracker,
|
||||
} from '@hooks/persisted';
|
||||
import { fetchChapter } from '@services/plugin/fetch';
|
||||
import { fetchChapter, fetchPage } from '@services/plugin/fetch';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
import {
|
||||
RefObject,
|
||||
@@ -55,8 +57,13 @@ export default function useChapter(
|
||||
const [[nextChapter, prevChapter], setAdjacentChapter] = useState<
|
||||
ChapterInfo[] | undefined[]
|
||||
>([]);
|
||||
const { autoScroll, autoScrollInterval, autoScrollOffset, useVolumeButtons } =
|
||||
useChapterGeneralSettings();
|
||||
const {
|
||||
autoScroll,
|
||||
autoScrollInterval,
|
||||
autoScrollOffset,
|
||||
useVolumeButtons,
|
||||
volumeButtonsOffset,
|
||||
} = useChapterGeneralSettings();
|
||||
const { incognitoMode } = useLibrarySettings();
|
||||
const [error, setError] = useState<string>();
|
||||
const { tracker } = useTracker();
|
||||
@@ -64,21 +71,21 @@ export default function useChapter(
|
||||
const { setImmersiveMode, showStatusAndNavBar } = useFullscreenMode();
|
||||
|
||||
const connectVolumeButton = useCallback(() => {
|
||||
const offset = defaultTo(
|
||||
volumeButtonsOffset,
|
||||
Math.round(Dimensions.get('window').height * 0.75),
|
||||
);
|
||||
emmiter.addListener('VolumeUp', () => {
|
||||
webViewRef.current?.injectJavaScript(`(()=>{
|
||||
window.scrollBy({top: -${
|
||||
Dimensions.get('window').height * 0.75
|
||||
}, behavior: 'smooth'})
|
||||
})()`);
|
||||
window.scrollBy({top: -${offset}, behavior: 'smooth'})
|
||||
})()`);
|
||||
});
|
||||
emmiter.addListener('VolumeDown', () => {
|
||||
webViewRef.current?.injectJavaScript(`(()=>{
|
||||
window.scrollBy({top: ${
|
||||
Dimensions.get('window').height * 0.75
|
||||
}, behavior: 'smooth'})
|
||||
})()`);
|
||||
window.scrollBy({top: ${offset}, behavior: 'smooth'})
|
||||
})()`);
|
||||
});
|
||||
}, [webViewRef]);
|
||||
}, [webViewRef, volumeButtonsOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (useVolumeButtons) {
|
||||
@@ -120,11 +127,68 @@ export default function useChapter(
|
||||
const chap = navChapter ?? chapter;
|
||||
const cachedText = chapterTextCache.get(chap.id);
|
||||
const text = cachedText ?? loadChapterText(chap.id, chap.path);
|
||||
const [nextChap, prevChap, awaitedText] = await Promise.all([
|
||||
getNextChapter(chap.novelId, chap.position!, chap.page),
|
||||
getPrevChapter(chap.novelId, chap.position!, chap.page),
|
||||
text,
|
||||
]);
|
||||
const [nextChapResult, prevChapResult, awaitedText] = await Promise.all(
|
||||
[
|
||||
getNextChapter(chap.novelId, chap.position!, chap.page),
|
||||
getPrevChapter(chap.novelId, chap.position!, chap.page),
|
||||
text,
|
||||
],
|
||||
);
|
||||
|
||||
let nextChap = nextChapResult;
|
||||
let prevChap = prevChapResult;
|
||||
|
||||
// Pre-fetch adjacent page chapters if at a page boundary
|
||||
const currentPage = Number(chap.page);
|
||||
if (
|
||||
!nextChap &&
|
||||
novel.totalPages > 0 &&
|
||||
currentPage < novel.totalPages
|
||||
) {
|
||||
const nextPage = String(currentPage + 1);
|
||||
try {
|
||||
const count = await getChapterCount(chap.novelId, nextPage);
|
||||
if (count === 0) {
|
||||
const sourcePage = await fetchPage(
|
||||
novel.pluginId,
|
||||
novel.path,
|
||||
nextPage,
|
||||
);
|
||||
await insertChapters(
|
||||
chap.novelId,
|
||||
sourcePage.chapters.map(ch => ({ ...ch, page: nextPage })),
|
||||
);
|
||||
}
|
||||
nextChap = await getNextChapter(
|
||||
chap.novelId,
|
||||
chap.position!,
|
||||
chap.page,
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
if (!prevChap && currentPage > 1) {
|
||||
const prevPage = String(currentPage - 1);
|
||||
try {
|
||||
const count = await getChapterCount(chap.novelId, prevPage);
|
||||
if (count === 0) {
|
||||
const sourcePage = await fetchPage(
|
||||
novel.pluginId,
|
||||
novel.path,
|
||||
prevPage,
|
||||
);
|
||||
await insertChapters(
|
||||
chap.novelId,
|
||||
sourcePage.chapters.map(ch => ({ ...ch, page: prevPage })),
|
||||
);
|
||||
}
|
||||
prevChap = await getPrevChapter(
|
||||
chap.novelId,
|
||||
chap.position!,
|
||||
chap.page,
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (nextChap && !chapterTextCache.get(nextChap.id)) {
|
||||
chapterTextCache.set(
|
||||
nextChap.id,
|
||||
@@ -158,6 +222,8 @@ export default function useChapter(
|
||||
setChapterText,
|
||||
novel.pluginId,
|
||||
novel.name,
|
||||
novel.path,
|
||||
novel.totalPages,
|
||||
setLoading,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -9,33 +9,65 @@ export const sanitizeChapterText = (
|
||||
): string => {
|
||||
const text = sanitizeHtml(html, {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
|
||||
'html',
|
||||
'head',
|
||||
'body',
|
||||
'link',
|
||||
'style',
|
||||
'meta',
|
||||
'del',
|
||||
'ins',
|
||||
'img',
|
||||
'i',
|
||||
'em',
|
||||
'b',
|
||||
'a',
|
||||
'div',
|
||||
'ol',
|
||||
'li',
|
||||
'audio',
|
||||
'video',
|
||||
'source',
|
||||
'object',
|
||||
'svg',
|
||||
'math',
|
||||
'title',
|
||||
'details',
|
||||
'summary',
|
||||
'ref',
|
||||
]),
|
||||
allowedAttributes: {
|
||||
'*': ['data-*'],
|
||||
a: ['href', 'class', 'id'],
|
||||
div: ['class', 'id'],
|
||||
img: ['src', 'class', 'id'],
|
||||
'*': [
|
||||
'data-*',
|
||||
'class',
|
||||
'id',
|
||||
'lang',
|
||||
'dir',
|
||||
'title',
|
||||
'epub:type',
|
||||
'role',
|
||||
'aria-label',
|
||||
'aria-labelledby',
|
||||
'aria-describedby',
|
||||
],
|
||||
a: ['href', 'name', 'target'],
|
||||
img: ['src', 'srcset', 'alt', 'width', 'height', 'loading'],
|
||||
ol: ['reversed', 'start', 'type'],
|
||||
p: ['class', 'id'],
|
||||
span: ['class', 'id'],
|
||||
ref: ['href', 'class', 'id'],
|
||||
ul: ['type'],
|
||||
li: ['value'],
|
||||
p: ['align'],
|
||||
ref: ['href'],
|
||||
blockquote: ['cite'],
|
||||
table: ['border', 'cellpadding', 'cellspacing', 'width', 'summary'],
|
||||
td: ['colspan', 'rowspan', 'align', 'valign'],
|
||||
th: ['colspan', 'rowspan', 'align', 'valign', 'scope'],
|
||||
audio: ['src', 'controls'],
|
||||
video: ['src', 'controls', 'width', 'height'],
|
||||
source: ['src', 'type', 'srcset'],
|
||||
svg: ['width', 'height', 'viewBox', 'xmlns'],
|
||||
q: ['cite'],
|
||||
time: ['datetime'],
|
||||
del: ['cite', 'datetime'],
|
||||
ins: ['cite', 'datetime'],
|
||||
link: ['rel', 'type', 'href', 'media'],
|
||||
meta: ['charset', 'name', 'content', 'http-equiv'],
|
||||
},
|
||||
allowedSchemes: ['data', 'http', 'https', 'file'],
|
||||
});
|
||||
|
||||
// Return the sanitized and updated HTML or an error message
|
||||
return (
|
||||
text ||
|
||||
getString('readerScreen.emptyChapterMessage', {
|
||||
|
||||
@@ -130,9 +130,8 @@ const SettingsReaderScreen = () => {
|
||||
|
||||
@font-face {
|
||||
font-family: ${readerSettings.fontFamily};
|
||||
src: url("file:///android_asset/fonts/${
|
||||
readerSettings.fontFamily
|
||||
}.ttf");
|
||||
src: url("file:///android_asset/fonts/${readerSettings.fontFamily
|
||||
}.ttf");
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -171,7 +170,7 @@ const SettingsReaderScreen = () => {
|
||||
return (
|
||||
<SafeAreaView
|
||||
excludeTop
|
||||
style={{ flex: 1, backgroundColor: readerBackgroundColor }}
|
||||
style={[styles.container, { backgroundColor: readerBackgroundColor }]}
|
||||
>
|
||||
<Appbar
|
||||
mode="small"
|
||||
@@ -181,7 +180,7 @@ const SettingsReaderScreen = () => {
|
||||
/>
|
||||
|
||||
{/* Large Preview Area */}
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={styles.previewContainer}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
originWhitelist={['*']}
|
||||
@@ -189,7 +188,7 @@ const SettingsReaderScreen = () => {
|
||||
scalesPageToFit={true}
|
||||
showsVerticalScrollIndicator={false}
|
||||
javaScriptEnabled={true}
|
||||
style={{ flex: 1, backgroundColor: readerBackgroundColor }}
|
||||
style={[styles.webView, { backgroundColor: readerBackgroundColor }]}
|
||||
nestedScrollEnabled={true}
|
||||
onMessage={(ev: { nativeEvent: { data: string } }) => {
|
||||
const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data);
|
||||
@@ -232,8 +231,7 @@ const SettingsReaderScreen = () => {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
${webViewCSS}
|
||||
</head>
|
||||
<body class="${
|
||||
chapterGeneralSettings.pageReader ? 'page-reader' : ''
|
||||
<body class="${chapterGeneralSettings.pageReader ? 'page-reader' : ''
|
||||
}">
|
||||
<div id="LNReader-chapter">
|
||||
${dummyHTML}
|
||||
@@ -242,24 +240,24 @@ const SettingsReaderScreen = () => {
|
||||
</body>
|
||||
<script>
|
||||
var initialReaderConfig = ${JSON.stringify({
|
||||
readerSettings,
|
||||
chapterGeneralSettings,
|
||||
novel,
|
||||
chapter,
|
||||
nextChapter: chapter,
|
||||
batteryLevel,
|
||||
autoSaveInterval: 2222,
|
||||
DEBUG: __DEV__,
|
||||
strings: {
|
||||
finished: `${getString(
|
||||
'readerScreen.finished',
|
||||
)}: ${chapter.name.trim()}`,
|
||||
nextChapter: getString('readerScreen.nextChapter', {
|
||||
name: chapter.name,
|
||||
}),
|
||||
noNextChapter: getString('readerScreen.noNextChapter'),
|
||||
},
|
||||
})}
|
||||
readerSettings,
|
||||
chapterGeneralSettings,
|
||||
novel,
|
||||
chapter,
|
||||
nextChapter: chapter,
|
||||
batteryLevel,
|
||||
autoSaveInterval: 2222,
|
||||
DEBUG: __DEV__,
|
||||
strings: {
|
||||
finished: `${getString(
|
||||
'readerScreen.finished',
|
||||
)}: ${chapter.name.trim()}`,
|
||||
nextChapter: getString('readerScreen.nextChapter', {
|
||||
name: chapter.name,
|
||||
}),
|
||||
noNextChapter: getString('readerScreen.noNextChapter'),
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
<script src="${assetsUriPrefix}/js/icons.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/van.js"></script>
|
||||
@@ -351,4 +349,13 @@ const styles = StyleSheet.create({
|
||||
tabContent: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
previewContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
theme,
|
||||
}) => {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.surface }]}>
|
||||
<View style={styles.container}>
|
||||
{tabs.map(tab => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
@@ -31,9 +31,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
key={tab.id}
|
||||
style={[
|
||||
styles.tab,
|
||||
{
|
||||
borderBottomColor: isActive ? theme.primary : 'transparent',
|
||||
},
|
||||
isActive && { borderBottomColor: theme.primary },
|
||||
]}
|
||||
onPress={() => onTabChange(tab.id)}
|
||||
android_ripple={{
|
||||
@@ -60,6 +58,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0, 0, 0, 0.12)',
|
||||
backgroundColor: 'transparent', // Will be set by theme if needed, but the lint was about static style
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user