Compare commits
53 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 | |||
| d43c95f98c | |||
| 52d9d9ea1d | |||
| 2cabc9849c | |||
| 17049aca1c | |||
| 9608d59efe | |||
| a7942212a0 | |||
| a22aa53c51 | |||
| 1ce16dc643 | |||
| 0ed6e9a463 | |||
| b7ecd1d749 | |||
| 44e177a5ca | |||
| b09cd3d587 | |||
| b70ebcf5f9 | |||
| 9d58161729 | |||
| e07eed4a95 | |||
| e09704c711 | |||
| 3af24f0815 | |||
| 4c0e7f6013 | |||
| ea298273af | |||
| f60a5af3f2 | |||
| 6ae5c1cdac |
@@ -45,7 +45,7 @@ body:
|
||||
label: LNReader version
|
||||
description: You can find your LNReader version in **More → About**.
|
||||
placeholder: |
|
||||
Example: "2.0.0"
|
||||
Example: "2.0.3"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -86,9 +86,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 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.0](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.0](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
|
||||
|
||||
|
||||
@@ -88,3 +88,7 @@ flake.lock
|
||||
|
||||
# pnpm
|
||||
.pnpm-store
|
||||
|
||||
.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 = 0
|
||||
versionPatch = 3
|
||||
}
|
||||
|
||||
android {
|
||||
|
||||
@@ -140,6 +140,10 @@ window.tts = new (function () {
|
||||
this.currentElement = reader.chapterElement;
|
||||
this.started = false;
|
||||
this.reading = false;
|
||||
this.elementsRead = 0;
|
||||
this.totalElements = 0;
|
||||
this.allReadableElements = []; // Store all readable elements at start
|
||||
this.textQueue = []; // Flat list of normalized text for native fallback
|
||||
|
||||
this.readable = element => {
|
||||
const ele = element ?? this.currentElement;
|
||||
@@ -169,22 +173,41 @@ window.tts = new (function () {
|
||||
};
|
||||
|
||||
// if can find a readable node, else stop tts
|
||||
this.findNextTextNode = () => {
|
||||
// FIXED: Added proper boundary checks to prevent stack overflow
|
||||
this.findNextTextNode = (depth = 0) => {
|
||||
// Prevent deep recursion
|
||||
if (depth > 500) {
|
||||
console.warn('TTS: findNextTextNode max depth reached');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.currentElement.isSameNode(reader.chapterElement) && this.started) {
|
||||
return false;
|
||||
} else {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
// Safety check: ensure currentElement is valid
|
||||
if (!this.currentElement || !this.currentElement.nodeName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// is read, have to go next or go back
|
||||
if (this.currentElement.isSameNode(this.prevElement)) {
|
||||
this.prevElement = this.currentElement;
|
||||
if (this.currentElement.nextElementSibling) {
|
||||
this.currentElement = this.currentElement.nextElementSibling;
|
||||
} else {
|
||||
return this.findNextTextNode(depth + 1);
|
||||
} else if (
|
||||
this.currentElement.parentElement &&
|
||||
!this.currentElement.parentElement.isSameNode(document.body) &&
|
||||
!this.currentElement.parentElement.isSameNode(document.documentElement)
|
||||
) {
|
||||
this.currentElement = this.currentElement.parentElement;
|
||||
return this.findNextTextNode(depth + 1);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return this.findNextTextNode();
|
||||
} else {
|
||||
// can read? read it
|
||||
if (this.readable()) {
|
||||
@@ -197,50 +220,117 @@ window.tts = new (function () {
|
||||
// go deep
|
||||
this.prevElement = this.currentElement;
|
||||
this.currentElement = this.currentElement.firstElementChild;
|
||||
return this.findNextTextNode(depth + 1);
|
||||
} else if (this.currentElement.nextElementSibling) {
|
||||
this.prevElement = this.currentElement;
|
||||
this.currentElement = this.currentElement.nextElementSibling;
|
||||
} else {
|
||||
return this.findNextTextNode(depth + 1);
|
||||
} else if (
|
||||
this.currentElement.parentElement &&
|
||||
!this.currentElement.parentElement.isSameNode(document.body) &&
|
||||
!this.currentElement.parentElement.isSameNode(document.documentElement)
|
||||
) {
|
||||
this.prevElement = this.currentElement;
|
||||
this.currentElement = this.currentElement.parentElement;
|
||||
return this.findNextTextNode(depth + 1);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return this.findNextTextNode();
|
||||
}
|
||||
};
|
||||
|
||||
this.next = () => {
|
||||
try {
|
||||
this.currentElement?.classList?.remove('highlight');
|
||||
if (this.findNextTextNode()) {
|
||||
const text = this.normalizeText(this.currentElement?.innerText);
|
||||
|
||||
// Use array-based approach instead of DOM traversal (no recursion!)
|
||||
while (this.elementsRead < this.totalElements) {
|
||||
const nextElement = this.allReadableElements[this.elementsRead];
|
||||
if (!nextElement) break;
|
||||
|
||||
const text = this.normalizeText(nextElement.innerText);
|
||||
if (text) {
|
||||
// Found valid text - speak it
|
||||
this.currentElement = nextElement;
|
||||
this.reading = true;
|
||||
this.elementsRead++;
|
||||
this.speak();
|
||||
return;
|
||||
} else {
|
||||
this.next();
|
||||
// Empty text, skip to next in array (no recursion!)
|
||||
this.elementsRead++;
|
||||
}
|
||||
}
|
||||
|
||||
// Reached the end (elementsRead >= totalElements or no more valid elements)
|
||||
this.reading = false;
|
||||
const autoPageAdvance =
|
||||
reader.readerSettings.val.tts?.autoPageAdvance === true;
|
||||
const hasNextChapter = !!reader.nextChapter;
|
||||
|
||||
if (autoPageAdvance && hasNextChapter) {
|
||||
reader.post({ type: 'next', autoStartTTS: true });
|
||||
} else {
|
||||
this.reading = false;
|
||||
this.stop();
|
||||
const controller = document.getElementById('TTS-Controller');
|
||||
if (controller?.firstElementChild) {
|
||||
controller.firstElementChild.innerHTML = volumnIcon;
|
||||
}
|
||||
if (reader.nextChapter) {
|
||||
reader.post({ type: 'next', autoStartTTS: true });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
this.stop();
|
||||
alert('TTS Error: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
this.start = element => {
|
||||
this.stop();
|
||||
this.currentElement = element ?? reader.chapterElement;
|
||||
const startElement = element ?? reader.chapterElement;
|
||||
this.currentElement = startElement;
|
||||
|
||||
// Get all readable elements from the chapter
|
||||
this.allReadableElements = this.getAllReadableElements(
|
||||
reader.chapterElement,
|
||||
);
|
||||
this.totalElements = this.allReadableElements.length;
|
||||
this.textQueue = this.allReadableElements
|
||||
.map(el => this.normalizeText(el.innerText))
|
||||
.filter(text => !!text);
|
||||
reader.post({
|
||||
type: 'tts-queue',
|
||||
data: {
|
||||
queue: this.textQueue,
|
||||
startIndex: this.elementsRead,
|
||||
},
|
||||
});
|
||||
|
||||
// If starting from a specific element, count how many are before it
|
||||
if (element && element !== reader.chapterElement) {
|
||||
const startIndex = this.allReadableElements.indexOf(element);
|
||||
this.elementsRead = startIndex >= 0 ? startIndex : 0;
|
||||
} else {
|
||||
this.elementsRead = 0;
|
||||
}
|
||||
|
||||
this.next();
|
||||
};
|
||||
|
||||
// Get all readable elements in order
|
||||
this.getAllReadableElements = element => {
|
||||
const elements = [];
|
||||
const traverse = el => {
|
||||
if (!el) return;
|
||||
if (this.readable(el)) {
|
||||
elements.push(el);
|
||||
}
|
||||
for (let i = 0; i < el.children.length; i++) {
|
||||
traverse(el.children[i]);
|
||||
}
|
||||
};
|
||||
traverse(element);
|
||||
return elements;
|
||||
};
|
||||
|
||||
this.resume = () => {
|
||||
if (!this.reading) {
|
||||
if (
|
||||
@@ -268,7 +358,16 @@ window.tts = new (function () {
|
||||
this.currentElement = reader.chapterElement;
|
||||
this.started = false;
|
||||
this.reading = false;
|
||||
this.elementsRead = 0;
|
||||
this.totalElements = 0;
|
||||
this.allReadableElements = [];
|
||||
this.textQueue = [];
|
||||
reader.post({ type: 'tts-state', data: { isReading: false } });
|
||||
// Ensure icon updates to stopped state
|
||||
const controller = document.getElementById('TTS-Controller');
|
||||
if (controller?.firstElementChild) {
|
||||
controller.firstElementChild.innerHTML = volumnIcon;
|
||||
}
|
||||
};
|
||||
|
||||
this.isElementInViewport = element => {
|
||||
@@ -287,6 +386,7 @@ window.tts = new (function () {
|
||||
);
|
||||
};
|
||||
|
||||
// UPDATED: Scroll to top or center based on settings with padding for notch/camera
|
||||
this.scrollToElement = element => {
|
||||
if (!element) return;
|
||||
// Check if element is partially visible (at least some part is in viewport)
|
||||
@@ -301,11 +401,27 @@ window.tts = new (function () {
|
||||
|
||||
// Only scroll if element is not visible or barely visible
|
||||
if (!isPartiallyVisible || rect.top < 0 || rect.bottom > windowHeight) {
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
});
|
||||
// Check scrollToTop setting (default to true for better reading experience)
|
||||
const scrollToTop = reader.readerSettings.val.tts?.scrollToTop !== false;
|
||||
|
||||
if (scrollToTop) {
|
||||
// Scroll to top with padding for notch/camera (80px from top)
|
||||
const elementTop =
|
||||
element.getBoundingClientRect().top + window.pageYOffset;
|
||||
const offsetPosition = elementTop - 80; // 80px padding for notch/camera
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
} else {
|
||||
// Center scroll (original behavior)
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -316,7 +432,11 @@ window.tts = new (function () {
|
||||
this.currentElement.classList.add('highlight');
|
||||
const text = this.normalizeText(this.currentElement.innerText);
|
||||
if (text) {
|
||||
reader.post({ type: 'speak', data: text });
|
||||
reader.post({
|
||||
type: 'speak',
|
||||
data: text,
|
||||
index: this.elementsRead - 1,
|
||||
});
|
||||
reader.post({ type: 'tts-state', data: { isReading: true } });
|
||||
} else {
|
||||
this.next();
|
||||
@@ -324,6 +444,15 @@ window.tts = new (function () {
|
||||
};
|
||||
})();
|
||||
|
||||
// Watch for TTSEnable changes and stop TTS when disabled
|
||||
van.derive(() => {
|
||||
if (!reader.generalSettings.val.TTSEnable && window.tts) {
|
||||
if (tts.reading || tts.started) {
|
||||
tts.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.pageReader = new (function () {
|
||||
this.page = van.state(0);
|
||||
this.totalPages = van.state(0);
|
||||
|
||||
@@ -264,6 +264,10 @@ const TTSController = () => {
|
||||
{
|
||||
id: 'TTS-Controller',
|
||||
class: () => `${reader.generalSettings.val.TTSEnable ? '' : 'hidden'}`,
|
||||
style: () =>
|
||||
reader.generalSettings.val.TTSEnable
|
||||
? 'pointer-events: auto;'
|
||||
: 'pointer-events: none; display: none !important; opacity: 0; transition: none;',
|
||||
ontouchstart: () => {
|
||||
if (!controllerElement) {
|
||||
controllerElement = document.getElementById('TTS-Controller');
|
||||
@@ -303,7 +307,8 @@ const TTSController = () => {
|
||||
top = reader.layoutHeight - 120;
|
||||
}
|
||||
controllerElement.style.top = `${top}px`;
|
||||
if (hoverElement) {
|
||||
// Check if TTS is still enabled before starting
|
||||
if (hoverElement && reader.generalSettings.val.TTSEnable) {
|
||||
tts.start(hoverElement);
|
||||
controllerElement.firstElementChild.innerHTML = pauseIcon;
|
||||
}
|
||||
@@ -313,12 +318,19 @@ const TTSController = () => {
|
||||
},
|
||||
onclick: e => {
|
||||
e.stopPropagation();
|
||||
// Don't allow interaction if TTS is disabled
|
||||
if (!reader.generalSettings.val.TTSEnable) {
|
||||
return;
|
||||
}
|
||||
if (tts.reading) {
|
||||
tts.pause();
|
||||
controllerElement.firstElementChild.innerHTML = resumeIcon;
|
||||
} else {
|
||||
} else if (tts.started) {
|
||||
tts.resume();
|
||||
controllerElement.firstElementChild.innerHTML = pauseIcon;
|
||||
} else {
|
||||
tts.start();
|
||||
controllerElement.firstElementChild.innerHTML = pauseIcon;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+40
-38
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lnreader",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:android": "pnpm run generate:env:debug && react-native run-android --appIdSuffix \"debug\" --active-arch-only",
|
||||
@@ -41,84 +41,86 @@
|
||||
"dependencies": {
|
||||
"@cd-z/react-native-epub-creator": "^3.0.0",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@legendapp/list": "^2.0.14",
|
||||
"@react-native-community/slider": "^5.0.1",
|
||||
"@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",
|
||||
"@react-native-google-signin/google-signin": "^16.0.0",
|
||||
"@react-native-vector-icons/common": "^12.3.0",
|
||||
"@react-native-vector-icons/material-design-icons": "^12.3.0",
|
||||
"@react-native/codegen": "^0.81.1",
|
||||
"@react-native/gradle-plugin": "^0.81.1",
|
||||
"@react-navigation/bottom-tabs": "^7.4.7",
|
||||
"@react-navigation/native": "^7.1.17",
|
||||
"@react-navigation/native-stack": "^7.3.26",
|
||||
"@react-navigation/stack": "^7.4.8",
|
||||
"@react-native-vector-icons/common": "^12.4.0",
|
||||
"@react-native-vector-icons/material-design-icons": "^12.4.0",
|
||||
"@react-native/codegen": "^0.81.5",
|
||||
"@react-native/gradle-plugin": "^0.81.5",
|
||||
"@react-navigation/bottom-tabs": "^7.8.6",
|
||||
"@react-navigation/native": "^7.1.21",
|
||||
"@react-navigation/native-stack": "^7.8.0",
|
||||
"@react-navigation/stack": "^7.6.7",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"color": "^5.0.0",
|
||||
"dayjs": "^1.11.18",
|
||||
"expo": "^54.0.2",
|
||||
"color": "^5.0.3",
|
||||
"dayjs": "^1.11.19",
|
||||
"expo": "^54.0.25",
|
||||
"expo-clipboard": "~8.0.7",
|
||||
"expo-document-picker": "~14.0.7",
|
||||
"expo-file-system": "~19.0.12",
|
||||
"expo-file-system": "~19.0.19",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-keep-awake": "~15.0.7",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-linking": "~8.0.9",
|
||||
"expo-localization": "~17.0.7",
|
||||
"expo-navigation-bar": "~5.0.8",
|
||||
"expo-notifications": "~0.32.11",
|
||||
"expo-navigation-bar": "~5.0.9",
|
||||
"expo-notifications": "~0.32.13",
|
||||
"expo-speech": "~14.0.7",
|
||||
"expo-sqlite": "~16.0.8",
|
||||
"expo-web-browser": "~15.0.7",
|
||||
"expo-sqlite": "~16.0.9",
|
||||
"expo-web-browser": "~15.0.9",
|
||||
"htmlparser2": "^10.0.0",
|
||||
"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.4",
|
||||
"react-native": "^0.81.5",
|
||||
"react-native-background-actions": "^4.0.1",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
"react-native-device-info": "^14.1.1",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-drawer-layout": "^4.1.13",
|
||||
"react-native-drawer-layout": "^4.2.0",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-error-boundary": "^2.0.0",
|
||||
"react-native-file-access": "^3.2.0",
|
||||
"react-native-gesture-handler": "^2.28.0",
|
||||
"react-native-gesture-handler": "^2.29.1",
|
||||
"react-native-lottie-splash-screen": "^1.1.2",
|
||||
"react-native-mmkv": "^3.3.1",
|
||||
"react-native-mmkv": "^3.3.3",
|
||||
"react-native-pager-view": "^6.9.1",
|
||||
"react-native-paper": "^5.14.5",
|
||||
"react-native-reanimated": "^4.1.0",
|
||||
"react-native-reanimated": "^4.1.5",
|
||||
"react-native-saf-x": "^2.2.3",
|
||||
"react-native-safe-area-context": "^5.6.1",
|
||||
"react-native-screens": "^4.16.0",
|
||||
"react-native-safe-area-context": "^5.6.2",
|
||||
"react-native-screens": "^4.18.0",
|
||||
"react-native-shimmer-placeholder": "^2.0.9",
|
||||
"react-native-tab-view": "^4.1.3",
|
||||
"react-native-tab-view": "^4.2.0",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-webview": "13.15.0",
|
||||
"react-native-worklets": "^0.5.1",
|
||||
"react-native-worklets": "^0.6.1",
|
||||
"react-native-zip-archive": "^7.0.2",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"urlencode": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.4",
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
|
||||
"@babel/preset-env": "^7.28.3",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@react-native-community/cli": "^20.0.2",
|
||||
"@react-native-community/cli-platform-android": "^20.0.2",
|
||||
"@react-native-community/cli-platform-ios": "^20.0.2",
|
||||
"@react-native/babel-preset": "^0.81.1",
|
||||
"@react-native/eslint-config": "^0.81.1",
|
||||
"@react-native/metro-config": "^0.81.1",
|
||||
"@react-native/typescript-config": "^0.81.1",
|
||||
"@react-native/babel-preset": "^0.81.5",
|
||||
"@react-native/eslint-config": "^0.81.5",
|
||||
"@react-native/metro-config": "^0.81.5",
|
||||
"@react-native/typescript-config": "^0.81.5",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react": "~19.1.12",
|
||||
"@types/react": "~19.1.17",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
@@ -127,7 +129,7 @@
|
||||
"lint-staged": "^12.5.0",
|
||||
"prettier": "2.8.8",
|
||||
"react-native-dotenv": "^3.4.11",
|
||||
"typescript": "~5.9.2"
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
|
||||
Generated
+6171
-2825
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);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/* eslint-disable react-native/no-inline-styles */
|
||||
import React, { useCallback } from 'react';
|
||||
import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
|
||||
import { Pressable, View, StyleSheet } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
interface CustomBottomTabBarProps extends BottomTabBarProps {
|
||||
theme: ThemeColors;
|
||||
showLabelsInNav: boolean;
|
||||
renderIcon: ({
|
||||
color,
|
||||
route,
|
||||
}: {
|
||||
route: { name: string };
|
||||
color: string;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
function CustomBottomTabBar({
|
||||
navigation,
|
||||
state,
|
||||
descriptors,
|
||||
insets,
|
||||
theme,
|
||||
showLabelsInNav,
|
||||
renderIcon,
|
||||
}: CustomBottomTabBarProps) {
|
||||
const getLabelText = useCallback(
|
||||
(route: any) => {
|
||||
if (!showLabelsInNav && route.name !== state.routeNames[state.index]) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { options } = descriptors[route.key];
|
||||
const label =
|
||||
typeof options.tabBarLabel === 'string'
|
||||
? options.tabBarLabel
|
||||
: typeof options.title === 'string'
|
||||
? options.title
|
||||
: route.name;
|
||||
|
||||
return label;
|
||||
},
|
||||
[descriptors, showLabelsInNav, state.index, state.routeNames],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: theme.surface2 || theme.surface,
|
||||
paddingBottom: insets?.bottom || 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{state.routes.map((route, index) => {
|
||||
const label = getLabelText(route);
|
||||
const isFocused = state.index === index;
|
||||
const showLabel = (showLabelsInNav || isFocused) && label;
|
||||
|
||||
const onPress = () => {
|
||||
const event = navigation.emit({
|
||||
type: 'tabPress',
|
||||
target: route.key,
|
||||
canPreventDefault: true,
|
||||
});
|
||||
|
||||
if (!isFocused && !event.defaultPrevented) {
|
||||
navigation.navigate(route.name, route.params);
|
||||
}
|
||||
};
|
||||
|
||||
const onLongPress = () => {
|
||||
navigation.emit({
|
||||
type: 'tabLongPress',
|
||||
target: route.key,
|
||||
});
|
||||
};
|
||||
|
||||
const iconColor = isFocused
|
||||
? theme.onPrimaryContainer
|
||||
: theme.onSurfaceVariant;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={route.key}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
style={styles.pressable}
|
||||
>
|
||||
{/* Icon */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
{
|
||||
transitionProperty: ['width', 'backgroundColor'],
|
||||
transitionDuration: 250,
|
||||
transitionTimingFunction: 'ease-in-out',
|
||||
marginBottom: showLabel ? 4 : 20,
|
||||
width: isFocused ? 64 : 32,
|
||||
backgroundColor: isFocused
|
||||
? theme.primaryContainer
|
||||
: 'transparent',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{renderIcon({ color: iconColor, route })}
|
||||
</Animated.View>
|
||||
|
||||
{/* Label */}
|
||||
{showLabel ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color: isFocused ? theme.onSurface : theme.onSurfaceVariant,
|
||||
fontWeight: '500',
|
||||
},
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomBottomTabBar;
|
||||
export type { CustomBottomTabBarProps };
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 0,
|
||||
minHeight: 80,
|
||||
},
|
||||
pressable: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 4,
|
||||
position: 'relative',
|
||||
},
|
||||
iconContainer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
},
|
||||
label: {
|
||||
height: 16,
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
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,
|
||||
FadeOut,
|
||||
withTiming,
|
||||
ExitAnimationsValues,
|
||||
EntryAnimationsValues,
|
||||
withDelay,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
interface MenuProps {
|
||||
visible: boolean;
|
||||
onDismiss: () => void;
|
||||
anchor: React.ReactNode;
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
children: React.ReactNode;
|
||||
fullWidth?: boolean; // Full width of the anchor
|
||||
}
|
||||
|
||||
interface MenuItemProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
titleStyle?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
const Menu: React.FC<MenuProps> & { Item: React.FC<MenuItemProps> } = ({
|
||||
visible,
|
||||
onDismiss,
|
||||
anchor,
|
||||
contentStyle,
|
||||
children,
|
||||
fullWidth,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const duration = 250;
|
||||
|
||||
const [menuLayout, setMenuLayout] = useState<LayoutRectangle | null>(null);
|
||||
const [anchorLayout, setAnchorLayout] = useState<LayoutRectangle>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
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 });
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (visible) {
|
||||
measureAnchor();
|
||||
} else {
|
||||
setMenuLayout(null); // Reset layout when closed
|
||||
}
|
||||
}, [measureAnchor, visible]);
|
||||
|
||||
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}>
|
||||
{anchor}
|
||||
</View>
|
||||
|
||||
{visible && (
|
||||
<Portal>
|
||||
{/* Backdrop */}
|
||||
<Pressable style={StyleSheet.absoluteFillObject} onPress={onDismiss}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.backdrop,
|
||||
theme.isDark ? styles.backdropDark : styles.backdropLight,
|
||||
]}
|
||||
entering={backdropEntering}
|
||||
exiting={backdropExiting}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{/* Menu */}
|
||||
<Animated.View
|
||||
key={menuLayout ? 'ready' : 'measuring'}
|
||||
style={[
|
||||
styles.menuContainer,
|
||||
{ backgroundColor: theme.surface2 || theme.surface },
|
||||
contentStyle,
|
||||
menuPosition,
|
||||
]}
|
||||
onLayout={e => {
|
||||
setMenuLayout(e.nativeEvent.layout);
|
||||
}}
|
||||
entering={menuLayout ? menuEntering : undefined}
|
||||
exiting={menuLayout ? menuExiting : undefined}
|
||||
>
|
||||
<ScrollView style={{ maxHeight: screenHeight * 0.6 }}>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
style,
|
||||
titleStyle,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.menuItem, style]}
|
||||
onPress={onPress}
|
||||
android_ripple={{ color: theme.rippleColor, foreground: true }}
|
||||
>
|
||||
<Animated.Text
|
||||
style={[styles.menuItemText, { color: theme.onSurface }, titleStyle]}
|
||||
>
|
||||
{title}
|
||||
</Animated.Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
Menu.Item = MenuItem;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
menuContainer: {
|
||||
borderRadius: 4,
|
||||
elevation: 2,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 1,
|
||||
},
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 3,
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
zIndex: 1001,
|
||||
},
|
||||
backdrop: {
|
||||
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: 12,
|
||||
paddingVertical: 12,
|
||||
minHeight: 48,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '400',
|
||||
},
|
||||
});
|
||||
|
||||
export default Menu;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Pressable, StyleSheet, View, TextInput } from 'react-native';
|
||||
|
||||
import IconButtonV2 from '../IconButtonV2/IconButtonV2';
|
||||
import { ThemeColors } from '../../theme/types';
|
||||
import { Menu } from 'react-native-paper';
|
||||
import { Menu } from '@components';
|
||||
import { MaterialDesignIconName } from '@type/icon';
|
||||
|
||||
interface RightIcon {
|
||||
export interface RightIcon {
|
||||
iconName: MaterialDesignIconName;
|
||||
color?: string;
|
||||
onPress: () => void;
|
||||
@@ -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}
|
||||
@@ -112,7 +108,6 @@ const Searchbar: React.FC<SearcbarProps> = ({
|
||||
<Menu
|
||||
visible={extraMenu}
|
||||
onDismiss={() => showExtraMenu(false)}
|
||||
anchorPosition="bottom"
|
||||
anchor={
|
||||
<IconButtonV2
|
||||
name="dots-vertical"
|
||||
@@ -135,7 +130,9 @@ const Searchbar: React.FC<SearcbarProps> = ({
|
||||
}}
|
||||
onPress={() => {
|
||||
showExtraMenu(false);
|
||||
button.onPress();
|
||||
setTimeout(() => {
|
||||
button.onPress();
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -163,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 },
|
||||
});
|
||||
|
||||
@@ -18,3 +18,5 @@ export { default as SafeAreaView } from './SafeAreaView/SafeAreaView';
|
||||
export { default as Modal } from './Modal/Modal';
|
||||
export { SegmentedControl } from './SegmentedControl';
|
||||
export { DialogTitle } from './DialogTitle';
|
||||
export { default as BottomTabBar } from './BottomTabBar';
|
||||
export { default as Menu } from './Menu';
|
||||
|
||||
+19
-5
@@ -31,7 +31,7 @@ export const db = SQLite.openDatabaseSync(dbName);
|
||||
|
||||
/**
|
||||
* Creates the initial database schema for fresh installations
|
||||
* Sets up all tables, indexes, triggers and sets version to 1
|
||||
* Sets up all tables, indexes, triggers and sets version to 2
|
||||
*/
|
||||
const createInitialSchema = () => {
|
||||
db.execSync('PRAGMA journal_mode = WAL');
|
||||
@@ -52,7 +52,7 @@ const createInitialSchema = () => {
|
||||
db.runSync(createNovelTriggerQueryUpdate);
|
||||
db.runSync(createNovelTriggerQueryDelete);
|
||||
|
||||
db.execSync('PRAGMA user_version = 1');
|
||||
db.execSync('PRAGMA user_version = 2');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -65,9 +65,23 @@ export const initializeDatabase = () => {
|
||||
db.execSync('PRAGMA cache_size = 10000');
|
||||
db.execSync('PRAGMA foreign_keys = ON');
|
||||
|
||||
const userVersion =
|
||||
db.getFirstSync<{ user_version: number }>('PRAGMA user_version')
|
||||
?.user_version ?? 0;
|
||||
let userVersion = 0;
|
||||
try {
|
||||
const result = db.getFirstSync<{ user_version: number }>(
|
||||
'PRAGMA user_version',
|
||||
);
|
||||
userVersion = result?.user_version ?? 0;
|
||||
} catch (error) {
|
||||
// If PRAGMA query fails, assume fresh database
|
||||
if (__DEV__) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'Failed to get database version, assuming fresh install:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
userVersion = 0;
|
||||
}
|
||||
|
||||
if (userVersion === 0) {
|
||||
createInitialSchema();
|
||||
|
||||
@@ -7,10 +7,14 @@ const columnExists = (
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): boolean => {
|
||||
const columns = db.getAllSync<{ name: string }>(
|
||||
`PRAGMA table_info(${tableName})`,
|
||||
);
|
||||
return columns.some(col => col.name === columnName);
|
||||
try {
|
||||
const columns = db.getAllSync<{ name: string }>(
|
||||
`PRAGMA table_info(${tableName})`,
|
||||
);
|
||||
return columns.some(col => col.name === columnName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -23,86 +27,99 @@ export const migration002: Migration = {
|
||||
version: 2,
|
||||
description: 'Add counter columns and triggers to Novel table',
|
||||
migrate: db => {
|
||||
if (!columnExists(db, 'Novel', 'chaptersDownloaded')) {
|
||||
db.runSync(`
|
||||
ALTER TABLE Novel
|
||||
ADD COLUMN chaptersDownloaded INTEGER DEFAULT 0
|
||||
`);
|
||||
const addColumnSafely = (columnName: string, columnDefinition: string) => {
|
||||
if (!columnExists(db, 'Novel', columnName)) {
|
||||
try {
|
||||
db.runSync(`
|
||||
ALTER TABLE Novel
|
||||
ADD COLUMN ${columnName} ${columnDefinition}
|
||||
`);
|
||||
} catch (error) {
|
||||
// Gracefully handle ALTER TABLE failures (e.g., table doesn't exist)
|
||||
// Columns will be created when table is created in initial schema
|
||||
if (__DEV__) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Failed to add column ${columnName} to Novel table:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
addColumnSafely('chaptersDownloaded', 'INTEGER DEFAULT 0');
|
||||
addColumnSafely('chaptersUnread', 'INTEGER DEFAULT 0');
|
||||
addColumnSafely('totalChapters', 'INTEGER DEFAULT 0');
|
||||
addColumnSafely('lastReadAt', 'TEXT');
|
||||
addColumnSafely('lastUpdatedAt', 'TEXT');
|
||||
|
||||
// Verify all columns exist before running UPDATE queries to prevent SQL errors
|
||||
const allColumnsExist =
|
||||
columnExists(db, 'Novel', 'chaptersDownloaded') &&
|
||||
columnExists(db, 'Novel', 'chaptersUnread') &&
|
||||
columnExists(db, 'Novel', 'totalChapters') &&
|
||||
columnExists(db, 'Novel', 'lastReadAt') &&
|
||||
columnExists(db, 'Novel', 'lastUpdatedAt');
|
||||
|
||||
if (allColumnsExist) {
|
||||
try {
|
||||
db.runSync(`
|
||||
UPDATE Novel
|
||||
SET chaptersDownloaded = (
|
||||
SELECT COUNT(*)
|
||||
FROM Chapter
|
||||
WHERE Chapter.novelId = Novel.id
|
||||
AND Chapter.isDownloaded = 1
|
||||
)
|
||||
`);
|
||||
|
||||
db.runSync(`
|
||||
UPDATE Novel
|
||||
SET chaptersUnread = (
|
||||
SELECT COUNT(*)
|
||||
FROM Chapter
|
||||
WHERE Chapter.novelId = Novel.id
|
||||
AND Chapter.unread = 1
|
||||
)
|
||||
`);
|
||||
|
||||
db.runSync(`
|
||||
UPDATE Novel
|
||||
SET totalChapters = (
|
||||
SELECT COUNT(*)
|
||||
FROM Chapter
|
||||
WHERE Chapter.novelId = Novel.id
|
||||
)
|
||||
`);
|
||||
|
||||
db.runSync(`
|
||||
UPDATE Novel
|
||||
SET lastReadAt = (
|
||||
SELECT MAX(readTime)
|
||||
FROM Chapter
|
||||
WHERE Chapter.novelId = Novel.id
|
||||
)
|
||||
`);
|
||||
|
||||
db.runSync(`
|
||||
UPDATE Novel
|
||||
SET lastUpdatedAt = (
|
||||
SELECT MAX(updatedTime)
|
||||
FROM Chapter
|
||||
WHERE Chapter.novelId = Novel.id
|
||||
)
|
||||
`);
|
||||
} catch (error) {
|
||||
// Gracefully handle UPDATE failures - columns already added with defaults
|
||||
if (__DEV__) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'Failed to populate counter columns in Novel table:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!columnExists(db, 'Novel', 'chaptersUnread')) {
|
||||
db.runSync(`
|
||||
ALTER TABLE Novel
|
||||
ADD COLUMN chaptersUnread INTEGER DEFAULT 0
|
||||
`);
|
||||
}
|
||||
|
||||
if (!columnExists(db, 'Novel', 'totalChapters')) {
|
||||
db.runSync(`
|
||||
ALTER TABLE Novel
|
||||
ADD COLUMN totalChapters INTEGER DEFAULT 0
|
||||
`);
|
||||
}
|
||||
|
||||
if (!columnExists(db, 'Novel', 'lastReadAt')) {
|
||||
db.runSync(`
|
||||
ALTER TABLE Novel
|
||||
ADD COLUMN lastReadAt TEXT
|
||||
`);
|
||||
}
|
||||
|
||||
if (!columnExists(db, 'Novel', 'lastUpdatedAt')) {
|
||||
db.runSync(`
|
||||
ALTER TABLE Novel
|
||||
ADD COLUMN lastUpdatedAt TEXT
|
||||
`);
|
||||
}
|
||||
|
||||
db.runSync(`
|
||||
UPDATE Novel
|
||||
SET chaptersDownloaded = (
|
||||
SELECT COUNT(*)
|
||||
FROM Chapter
|
||||
WHERE Chapter.novelId = Novel.id
|
||||
AND Chapter.isDownloaded = 1
|
||||
)
|
||||
`);
|
||||
|
||||
db.runSync(`
|
||||
UPDATE Novel
|
||||
SET chaptersUnread = (
|
||||
SELECT COUNT(*)
|
||||
FROM Chapter
|
||||
WHERE Chapter.novelId = Novel.id
|
||||
AND Chapter.unread = 1
|
||||
)
|
||||
`);
|
||||
|
||||
db.runSync(`
|
||||
UPDATE Novel
|
||||
SET totalChapters = (
|
||||
SELECT COUNT(*)
|
||||
FROM Chapter
|
||||
WHERE Chapter.novelId = Novel.id
|
||||
)
|
||||
`);
|
||||
|
||||
db.runSync(`
|
||||
UPDATE Novel
|
||||
SET lastReadAt = (
|
||||
SELECT MAX(readTime)
|
||||
FROM Chapter
|
||||
WHERE Chapter.novelId = Novel.id
|
||||
)
|
||||
`);
|
||||
|
||||
db.runSync(`
|
||||
UPDATE Novel
|
||||
SET lastUpdatedAt = (
|
||||
SELECT MAX(updatedTime)
|
||||
FROM Chapter
|
||||
WHERE Chapter.novelId = Novel.id
|
||||
)
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -375,7 +407,7 @@ export const getUpdatedOverviewFromDb = () =>
|
||||
Novel.name AS novelName,
|
||||
Novel.cover AS novelCover,
|
||||
Novel.path AS novelPath,
|
||||
DATE(Chapter.updatedTime) AS updateDate, -- Extract the date from updatedTime
|
||||
DATE(Chapter.updatedTime) AS updateDate,
|
||||
COUNT(*) AS updatesPerDay
|
||||
FROM
|
||||
Chapter
|
||||
@@ -387,14 +419,10 @@ WHERE
|
||||
Chapter.updatedTime IS NOT NULL
|
||||
GROUP BY
|
||||
Novel.id,
|
||||
Novel.name,
|
||||
Novel.cover,
|
||||
Novel.path,
|
||||
DATE(Chapter.updatedTime) -- Group by date and novelId
|
||||
DATE(Chapter.updatedTime)
|
||||
ORDER BY
|
||||
novelId,
|
||||
updateDate;
|
||||
|
||||
updateDate DESC,
|
||||
novelId;
|
||||
`);
|
||||
|
||||
export const getDetailedUpdatesFromDb = async (
|
||||
@@ -424,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,17 +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[],
|
||||
],
|
||||
]);
|
||||
runAsync(
|
||||
chapters.map(chapter => [
|
||||
restoreObjectQuery('Chapter', chapter),
|
||||
Object.values(chapter) 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) {
|
||||
for (const chapter of chapters) {
|
||||
await db.runAsync(
|
||||
restoreObjectQuery('Chapter', chapter),
|
||||
...(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);
|
||||
});
|
||||
}
|
||||
@@ -49,10 +49,19 @@ export class MigrationRunner {
|
||||
}
|
||||
|
||||
private getCurrentVersion(db: SQLiteDatabase): number {
|
||||
return (
|
||||
db.getFirstSync<{ user_version: number }>('PRAGMA user_version')
|
||||
?.user_version ?? 0
|
||||
);
|
||||
try {
|
||||
const result = db.getFirstSync<{ user_version: number }>(
|
||||
'PRAGMA user_version',
|
||||
);
|
||||
return result?.user_version ?? 0;
|
||||
} catch (error) {
|
||||
// If PRAGMA query fails, assume version 0 (fresh database)
|
||||
if (__DEV__) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Failed to get database version, assuming 0:', error);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private setVersion(db: SQLiteDatabase, version: number): void {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
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';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { getErrorMessage } from '@utils/error';
|
||||
|
||||
interface GithubUpdate {
|
||||
isNewVersion: boolean;
|
||||
@@ -32,7 +30,7 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
|
||||
return timeSinceLastCheck >= ONE_DAY_MS;
|
||||
};
|
||||
|
||||
const checkForRelease = async () => {
|
||||
const checkForRelease = useCallback(async () => {
|
||||
if (!shouldCheckForUpdate()) {
|
||||
setChecking(false);
|
||||
return;
|
||||
@@ -40,12 +38,23 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
|
||||
|
||||
try {
|
||||
const res = await fetch(latestReleaseUrl);
|
||||
|
||||
if (!res.ok) {
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!data || !data.tag_name) {
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const release = {
|
||||
tag_name: data.tag_name,
|
||||
body: data.body,
|
||||
downloadUrl: data.assets[0].browser_download_url,
|
||||
downloadUrl: data.assets?.[0]?.browser_download_url || undefined,
|
||||
};
|
||||
|
||||
MMKVStorage.set(LAST_UPDATE_CHECK_KEY, Date.now());
|
||||
@@ -53,14 +62,14 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
|
||||
setLatestRelease(release);
|
||||
setChecking(false);
|
||||
} catch (error) {
|
||||
showToast(`Failed to check for updates: ${getErrorMessage(error)}`);
|
||||
// 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, '');
|
||||
|
||||
@@ -69,19 +78,17 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
|
||||
|
||||
useEffect(() => {
|
||||
checkForRelease();
|
||||
}, []);
|
||||
}, [checkForRelease]);
|
||||
|
||||
if (!checking && shouldCheckForUpdate()) {
|
||||
const data = {
|
||||
if (!checking && latestRelease?.tag_name) {
|
||||
return {
|
||||
latestRelease,
|
||||
isNewVersion: isNewVersion(latestRelease.tag_name),
|
||||
};
|
||||
|
||||
return data;
|
||||
} else {
|
||||
return {
|
||||
latestRelease: undefined,
|
||||
isNewVersion: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
latestRelease: undefined,
|
||||
isNewVersion: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -110,6 +111,8 @@ export interface ChapterReaderSettings {
|
||||
voice?: Voice;
|
||||
rate?: number;
|
||||
pitch?: number;
|
||||
autoPageAdvance?: boolean;
|
||||
scrollToTop?: boolean;
|
||||
};
|
||||
epubLocation: string;
|
||||
epubUseAppTheme: boolean;
|
||||
@@ -172,6 +175,7 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
|
||||
swipeGestures: false,
|
||||
showScrollPercentage: true,
|
||||
useVolumeButtons: false,
|
||||
volumeButtonsOffset: null,
|
||||
showBatteryAndTime: false,
|
||||
autoScroll: false,
|
||||
autoScrollInterval: 10,
|
||||
@@ -180,7 +184,7 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
|
||||
removeExtraParagraphSpacing: false,
|
||||
bionicReading: false,
|
||||
tapToScroll: false,
|
||||
TTSEnable: false,
|
||||
TTSEnable: true,
|
||||
};
|
||||
|
||||
export const initialChapterReaderSettings: ChapterReaderSettings = {
|
||||
@@ -197,6 +201,8 @@ export const initialChapterReaderSettings: ChapterReaderSettings = {
|
||||
tts: {
|
||||
rate: 1,
|
||||
pitch: 1,
|
||||
autoPageAdvance: false,
|
||||
scrollToTop: true,
|
||||
},
|
||||
epubLocation: '',
|
||||
epubUseAppTheme: false,
|
||||
@@ -268,9 +274,23 @@ export const useChapterGeneralSettings = () => {
|
||||
};
|
||||
|
||||
export const useChapterReaderSettings = () => {
|
||||
const [chapterReaderSettings = initialChapterReaderSettings, setSettings] =
|
||||
const [storedSettings = initialChapterReaderSettings, setSettings] =
|
||||
useMMKVObject<ChapterReaderSettings>(CHAPTER_READER_SETTINGS);
|
||||
|
||||
// Ensure TTS settings have proper defaults (migration for existing users)
|
||||
const chapterReaderSettings = {
|
||||
...storedSettings,
|
||||
tts: {
|
||||
...initialChapterReaderSettings.tts,
|
||||
...storedSettings.tts,
|
||||
// Explicitly ensure these defaults if undefined
|
||||
autoPageAdvance: storedSettings.tts?.autoPageAdvance ?? false,
|
||||
scrollToTop: storedSettings.tts?.scrollToTop ?? true,
|
||||
rate: storedSettings.tts?.rate ?? 1,
|
||||
pitch: storedSettings.tts?.pitch ?? 1,
|
||||
},
|
||||
};
|
||||
|
||||
const setChapterReaderSettings = (values: Partial<ChapterReaderSettings>) =>
|
||||
setSettings({ ...chapterReaderSettings, ...values });
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
BottomTabBarProps,
|
||||
createBottomTabNavigator,
|
||||
} from '@react-navigation/bottom-tabs';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
|
||||
import Library from '../screens/library/LibraryScreen';
|
||||
import Updates from '../screens/updates/UpdatesScreen';
|
||||
@@ -15,8 +12,7 @@ import { useAppSettings, usePlugins, useTheme } from '@hooks/persisted';
|
||||
import { BottomNavigatorParamList } from './types';
|
||||
import Icon from '@react-native-vector-icons/material-design-icons';
|
||||
import { MaterialDesignIconName } from '@type/icon';
|
||||
import { CommonActions } from '@react-navigation/native';
|
||||
import { BottomNavigation } from 'react-native-paper';
|
||||
import { BottomTabBar } from '@components';
|
||||
|
||||
const Tab = createBottomTabNavigator<BottomNavigatorParamList>();
|
||||
|
||||
@@ -63,53 +59,16 @@ const BottomNavigator = () => {
|
||||
[],
|
||||
);
|
||||
|
||||
const renderBottomBar = useCallback(
|
||||
({ navigation, state, descriptors, insets }: BottomTabBarProps) => (
|
||||
<BottomNavigation.Bar
|
||||
theme={{ colors: theme }}
|
||||
style={{
|
||||
backgroundColor: theme.surface2,
|
||||
}}
|
||||
navigationState={state}
|
||||
safeAreaInsets={insets}
|
||||
onTabPress={({ route, preventDefault }) => {
|
||||
const event = navigation.emit({
|
||||
type: 'tabPress',
|
||||
target: route.key,
|
||||
canPreventDefault: true,
|
||||
});
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
preventDefault();
|
||||
} else {
|
||||
navigation.dispatch({
|
||||
...CommonActions.navigate(route.name, route.params),
|
||||
target: state.key,
|
||||
});
|
||||
}
|
||||
}}
|
||||
const renderTabBar = useCallback(
|
||||
(props: any) => (
|
||||
<BottomTabBar
|
||||
{...props}
|
||||
theme={theme}
|
||||
showLabelsInNav={showLabelsInNav}
|
||||
renderIcon={renderIcon}
|
||||
getLabelText={({ route }) => {
|
||||
if (
|
||||
!showLabelsInNav &&
|
||||
route.name !== state.routeNames[state.index]
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { options } = descriptors[route.key];
|
||||
const label =
|
||||
typeof options.tabBarLabel === 'string'
|
||||
? options.tabBarLabel
|
||||
: typeof options.title === 'string'
|
||||
? options.title
|
||||
: route.name;
|
||||
|
||||
return label;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[renderIcon, showLabelsInNav, theme],
|
||||
[theme, showLabelsInNav, renderIcon],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -123,7 +82,7 @@ const BottomNavigator = () => {
|
||||
color: theme.onError,
|
||||
},
|
||||
})}
|
||||
tabBar={renderBottomBar}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -53,11 +53,15 @@ const CategoriesScreen = () => {
|
||||
|
||||
const systemCategories = categories.filter(cat => cat.id === 1);
|
||||
|
||||
const updatedOrderCategories = [...systemCategories, ...data];
|
||||
requestAnimationFrame(() => {
|
||||
setCategories(updatedOrderCategories);
|
||||
updateCategoryOrderInDb(updatedOrderCategories);
|
||||
});
|
||||
const updatedOrderCategories = [...systemCategories, ...data].map(
|
||||
(category, index) => ({
|
||||
...category,
|
||||
sort: index,
|
||||
}),
|
||||
);
|
||||
|
||||
setCategories(updatedOrderCategories);
|
||||
updateCategoryOrderInDb(updatedOrderCategories);
|
||||
};
|
||||
|
||||
const renderItem = ({
|
||||
|
||||
@@ -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);
|
||||
@@ -53,6 +53,7 @@ const FirstRoute = () => {
|
||||
estimatedItemSize={4}
|
||||
extraData={[filter]}
|
||||
data={libraryFilterList}
|
||||
keyExtractor={item => `filter_${item.filter}`}
|
||||
renderItem={({ item }) => (
|
||||
<Checkbox
|
||||
label={item.label}
|
||||
@@ -85,6 +86,7 @@ const SecondRoute = () => {
|
||||
data={librarySortOrderList}
|
||||
extraData={[sortOrder]}
|
||||
estimatedItemSize={5}
|
||||
keyExtractor={(item, index) => `sort_${index}_${item.ASC}`}
|
||||
renderItem={({ item }) => (
|
||||
<SortItem
|
||||
label={item.label}
|
||||
@@ -161,6 +163,7 @@ const ThirdRoute = () => {
|
||||
estimatedItemSize={4}
|
||||
data={displayModesList}
|
||||
extraData={[displayMode]}
|
||||
keyExtractor={item => `display_mode_${item.value}`}
|
||||
renderItem={({ item }) => (
|
||||
<RadioButton
|
||||
label={item.label}
|
||||
@@ -174,6 +177,12 @@ const ThirdRoute = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const bottomSheetSceneMap = SceneMap({
|
||||
first: FirstRoute,
|
||||
second: SecondRoute,
|
||||
third: ThirdRoute,
|
||||
});
|
||||
|
||||
const LibraryBottomSheet: React.FC<LibraryBottomSheetProps> = ({
|
||||
bottomSheetRef,
|
||||
style,
|
||||
@@ -182,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);
|
||||
@@ -212,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>
|
||||
@@ -246,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;
|
||||
@@ -9,7 +9,10 @@ import { Category, NovelInfo } from '@database/types';
|
||||
import { useLibrarySettings } from '@hooks/persisted';
|
||||
import { LibrarySortOrder } from '../constants/constants';
|
||||
import { switchNovelToLibraryQuery } from '@database/queries/NovelQueries';
|
||||
import ServiceManager, { BackgroundTask } from '@services/ServiceManager';
|
||||
import ServiceManager, {
|
||||
BackgroundTask,
|
||||
QueuedBackgroundTask,
|
||||
} from '@services/ServiceManager';
|
||||
import { useMMKVObject } from 'react-native-mmkv';
|
||||
|
||||
// type Library = Category & { novels: LibraryNovelInfo[] };
|
||||
@@ -75,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(
|
||||
@@ -89,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,
|
||||
@@ -106,17 +115,24 @@ export const useLibrary = (): UseLibraryReturnType => {
|
||||
getLibrary();
|
||||
});
|
||||
|
||||
const [taskQueue] = useMMKVObject<BackgroundTask[]>(
|
||||
ServiceManager.manager.STORE_KEY,
|
||||
);
|
||||
const [taskQueue] = useMMKVObject<
|
||||
Array<BackgroundTask | QueuedBackgroundTask>
|
||||
>(ServiceManager.manager.STORE_KEY);
|
||||
const restoreTasksCount = useMemo(
|
||||
() =>
|
||||
taskQueue?.filter(
|
||||
t =>
|
||||
t.name === 'LOCAL_RESTORE' ||
|
||||
t.name === 'DRIVE_RESTORE' ||
|
||||
t.name === 'SELF_HOST_RESTORE',
|
||||
).length || 0,
|
||||
taskQueue?.filter(t => {
|
||||
/**
|
||||
* Handle backward compatibility: check for new format first, then old format
|
||||
*/
|
||||
const taskName =
|
||||
(t as QueuedBackgroundTask)?.task?.name ||
|
||||
(t as BackgroundTask)?.name;
|
||||
return (
|
||||
taskName === 'LOCAL_RESTORE' ||
|
||||
taskName === 'DRIVE_RESTORE' ||
|
||||
taskName === 'SELF_HOST_RESTORE'
|
||||
);
|
||||
}).length || 0,
|
||||
[taskQueue],
|
||||
);
|
||||
const prevRestoreTasksCountRef = useRef(restoreTasksCount);
|
||||
@@ -126,7 +142,7 @@ export const useLibrary = (): UseLibraryReturnType => {
|
||||
getLibrary();
|
||||
}
|
||||
prevRestoreTasksCountRef.current = restoreTasksCount;
|
||||
}, [restoreTasksCount]);
|
||||
}, [getLibrary, restoreTasksCount]);
|
||||
|
||||
return {
|
||||
library,
|
||||
@@ -146,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}
|
||||
@@ -59,8 +59,8 @@ const AboutScreen = ({ navigation }: AboutScreenProps) => {
|
||||
<List.Divider theme={theme} />
|
||||
<List.Item
|
||||
title={getString('aboutScreen.website')}
|
||||
description="https://lnreader.github.io"
|
||||
onPress={() => Linking.openURL('https://lnreader.github.io')}
|
||||
description="https://lnreader.app"
|
||||
onPress={() => Linking.openURL('https://lnreader.app')}
|
||||
theme={theme}
|
||||
/>
|
||||
<List.Item
|
||||
@@ -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,34 +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)}
|
||||
chapters={chapters}
|
||||
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}
|
||||
@@ -339,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>
|
||||
|
||||
@@ -12,6 +12,10 @@ 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,
|
||||
@@ -21,22 +25,26 @@ import {
|
||||
interface JumpToChapterModalProps {
|
||||
hideModal: () => void;
|
||||
modalVisible: boolean;
|
||||
chapters: ChapterInfo[];
|
||||
navigation: NovelScreenProps['navigation'];
|
||||
novel: NovelInfo;
|
||||
chapters: ChapterInfo[];
|
||||
chapterListRef: React.RefObject<LegendListRef | null>;
|
||||
loadUpToBatch: (batch: number) => Promise<void>;
|
||||
totalChapters?: number;
|
||||
}
|
||||
|
||||
const JumpToChapterModal = ({
|
||||
hideModal,
|
||||
modalVisible,
|
||||
chapters,
|
||||
chapters: loadedChapters,
|
||||
navigation,
|
||||
novel,
|
||||
chapterListRef,
|
||||
loadUpToBatch,
|
||||
totalChapters,
|
||||
}: JumpToChapterModalProps) => {
|
||||
const minNumber = Math.min(...chapters.map(c => c.chapterNumber || -1));
|
||||
const maxNumber = Math.max(...chapters.map(c => c.chapterNumber || -1));
|
||||
const minNumber = 1;
|
||||
const maxNumber = totalChapters ?? -1;
|
||||
const theme = useTheme();
|
||||
const [mode, setMode] = useState(false);
|
||||
const [openChapter, setOpenChapter] = useState(false);
|
||||
@@ -65,22 +73,29 @@ const JumpToChapterModal = ({
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToChapter = (chap: ChapterInfo) => {
|
||||
const scrollToChapter = async (chap: ChapterInfo) => {
|
||||
onDismiss();
|
||||
chapterListRef.current?.scrollToItem({
|
||||
animated: true,
|
||||
item: chap,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
};
|
||||
const loadedIndex = loadedChapters.findIndex(c => c.id === chap.id);
|
||||
if (loadedIndex >= 0) {
|
||||
chapterListRef.current?.scrollToIndex({
|
||||
animated: true,
|
||||
index: loadedIndex,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollToIndex = (index: number) => {
|
||||
onDismiss();
|
||||
chapterListRef.current?.scrollToIndex({
|
||||
animated: true,
|
||||
index: index,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const executeFunction = (item: ChapterInfo) => {
|
||||
@@ -113,44 +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 = chapters.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 {
|
||||
return scrollToChapter(chapter);
|
||||
}
|
||||
} else {
|
||||
const index = chapters.findIndex(c => c.chapterNumber === num);
|
||||
return scrollToIndex(index);
|
||||
}
|
||||
}
|
||||
|
||||
return setError(
|
||||
getString('novelScreen.jumpToChapterModal.error.validChapterNumber') +
|
||||
` (${num < minNumber ? '≥ ' + minNumber : '≤ ' + maxNumber})`,
|
||||
);
|
||||
} else {
|
||||
const searchedChapters = chapters.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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -208,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 ? (
|
||||
@@ -226,6 +243,7 @@ const JumpToChapterModal = ({
|
||||
data={result}
|
||||
extraData={openChapter}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={item => `chapter_${item.id}`}
|
||||
contentContainerStyle={styles.listContentCtn}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { getString } from '@strings/translations';
|
||||
import { Appbar, Menu as DefaultMenu } from 'react-native-paper';
|
||||
import { Appbar } from 'react-native-paper';
|
||||
import { Menu as DefaultMenu } from '@components';
|
||||
import { ThemeColors } from '@theme/types';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
@@ -16,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,
|
||||
@@ -30,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();
|
||||
@@ -96,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;
|
||||
@@ -103,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 [
|
||||
{
|
||||
@@ -150,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
|
||||
@@ -159,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}>
|
||||
@@ -167,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}
|
||||
@@ -186,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>
|
||||
@@ -224,7 +255,7 @@ const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
end: 0,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export default function NovelDrawer({
|
||||
recycleItems
|
||||
extraData={pageIndex}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) => `novel_page_${index}_${item}`}
|
||||
estimatedItemSize={60}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -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
|
||||
currentPage={pageIndex + 1}
|
||||
totalPages={pages.length}
|
||||
onPageChange={pageNum => openPage(pageNum - 1)}
|
||||
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,
|
||||
},
|
||||
]}
|
||||
@@ -95,6 +93,7 @@ export default function PageNavigationBottomSheet({
|
||||
recycleItems
|
||||
extraData={pageIndex}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) => `page_${index}_${item}`}
|
||||
estimatedItemSize={56}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
|
||||
@@ -7,72 +7,79 @@ import { Row } from '@components/Common';
|
||||
import { borderColor } from '@theme/colors';
|
||||
|
||||
interface PagePaginationControlProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
pages: string[];
|
||||
currentPageIndex: number;
|
||||
onPageChange: (pageIndex: number) => void;
|
||||
onOpenDrawer: () => void;
|
||||
theme: ThemeColors;
|
||||
}
|
||||
|
||||
const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
pages,
|
||||
currentPageIndex,
|
||||
onPageChange,
|
||||
onOpenDrawer,
|
||||
theme,
|
||||
}) => {
|
||||
const pageNumbers = useMemo(() => {
|
||||
const pages: (number | 'ellipsis')[] = [];
|
||||
const totalPages = pages.length;
|
||||
|
||||
const pageIndices = useMemo(() => {
|
||||
const indices: (number | 'ellipsis')[] = [];
|
||||
|
||||
if (totalPages <= 3) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
indices.push(i);
|
||||
}
|
||||
} else {
|
||||
if (currentPage !== 1) {
|
||||
pages.push(1);
|
||||
// Always show first page if not current
|
||||
if (currentPageIndex !== 0) {
|
||||
indices.push(0);
|
||||
}
|
||||
|
||||
const leftPage = currentPage - 1;
|
||||
if (leftPage > 1) {
|
||||
if (leftPage > 2) {
|
||||
pages.push('ellipsis');
|
||||
// Show page before current (with ellipsis if there's a gap)
|
||||
const leftPageIndex = currentPageIndex - 1;
|
||||
if (leftPageIndex > 0) {
|
||||
if (leftPageIndex > 1) {
|
||||
indices.push('ellipsis');
|
||||
}
|
||||
pages.push(leftPage);
|
||||
}
|
||||
|
||||
pages.push(currentPage);
|
||||
// Always show current page
|
||||
indices.push(currentPageIndex);
|
||||
|
||||
if (currentPage < totalPages - 1) {
|
||||
pages.push('ellipsis');
|
||||
// Show ellipsis after current only if there's a gap to last page
|
||||
const rightPageIndex = currentPageIndex + 1;
|
||||
if (rightPageIndex < totalPages - 1) {
|
||||
indices.push('ellipsis');
|
||||
}
|
||||
|
||||
if (totalPages !== currentPage) {
|
||||
pages.push(totalPages);
|
||||
// Always show last page if not current
|
||||
if (currentPageIndex !== totalPages - 1) {
|
||||
indices.push(totalPages - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}, [currentPage, totalPages]);
|
||||
return indices;
|
||||
}, [currentPageIndex, totalPages]);
|
||||
|
||||
const canGoPrevious = currentPage > 1;
|
||||
const canGoNext = currentPage < totalPages;
|
||||
const canGoPrevious = currentPageIndex > 0;
|
||||
const canGoNext = currentPageIndex < totalPages - 1;
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (canGoPrevious) {
|
||||
onPageChange(currentPage - 1);
|
||||
onPageChange(currentPageIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (canGoNext) {
|
||||
onPageChange(currentPage + 1);
|
||||
onPageChange(currentPageIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePagePress = (page: number) => {
|
||||
if (page !== currentPage) {
|
||||
onPageChange(page);
|
||||
const handlePagePress = (pageIndex: number) => {
|
||||
if (pageIndex !== currentPageIndex) {
|
||||
onPageChange(pageIndex);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,8 +108,8 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
|
||||
</Pressable>
|
||||
|
||||
<Row style={styles.pageNumbersRow}>
|
||||
{pageNumbers.map((page, index) => {
|
||||
if (page === 'ellipsis') {
|
||||
{pageIndices.map((pageIndex, index) => {
|
||||
if (pageIndex === 'ellipsis') {
|
||||
return (
|
||||
<Pressable
|
||||
key={`ellipsis-${index}`}
|
||||
@@ -124,18 +131,19 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = page === currentPage;
|
||||
const isActive = pageIndex === currentPageIndex;
|
||||
const pageName = pages[pageIndex];
|
||||
return (
|
||||
<Pressable
|
||||
key={page}
|
||||
key={`page-${pageIndex}`}
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
borderColor: isActive ? 'transparent' : borderColor,
|
||||
backgroundColor: isActive ? theme.primary : theme.surface,
|
||||
},
|
||||
isActive ? styles.activeButton : styles.inactiveButton,
|
||||
]}
|
||||
onPress={() => handlePagePress(page)}
|
||||
onPress={() => handlePagePress(pageIndex)}
|
||||
android_ripple={{
|
||||
color: isActive
|
||||
? color(theme.onPrimary).alpha(0.2).string()
|
||||
@@ -147,11 +155,12 @@ const PagePaginationControl: React.FC<PagePaginationControlProps> = ({
|
||||
styles.pageText,
|
||||
{
|
||||
color: isActive ? theme.onPrimary : theme.onSurface,
|
||||
fontWeight: isActive ? '600' : '400',
|
||||
},
|
||||
isActive ? styles.activePageText : styles.inactivePageText,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{page}
|
||||
{pageName}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -194,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,
|
||||
@@ -220,12 +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: 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,12 +161,14 @@ const ChapterDrawer = () => {
|
||||
recycleItems
|
||||
viewabilityConfig={{
|
||||
minimumViewTime: 100,
|
||||
viewAreaCoveragePercentThreshold: 95,
|
||||
itemVisiblePercentThreshold: 90,
|
||||
}}
|
||||
onViewableItemsChanged={checkViewableItems}
|
||||
data={chapters}
|
||||
extraData={[chapter, scrollToIndex.current]}
|
||||
keyExtractor={item => (item.position ?? item.id).toString()}
|
||||
keyExtractor={item =>
|
||||
`chapter_${item.id}_${item.position ?? 'no_pos'}`
|
||||
}
|
||||
renderItem={val =>
|
||||
renderListChapter({
|
||||
item: val.item,
|
||||
@@ -174,6 +183,12 @@ const ChapterDrawer = () => {
|
||||
}
|
||||
estimatedItemSize={60}
|
||||
initialScrollIndex={scrollToIndex.current}
|
||||
onEndReached={
|
||||
batchInformation.batch < batchInformation.total && !fetching
|
||||
? getNextChapterBatch
|
||||
: noop
|
||||
}
|
||||
onEndReachedThreshold={6}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.footer}>
|
||||
@@ -220,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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user