🚀 Nested Chapters in Reader
* Fixed issue with incorrect navigation * Added Nested Chapters, e.g. Book -> Chapter Resolves: #130
This commit is contained in:
parent
804c7d24a9
commit
cd18c430a5
12 changed files with 215 additions and 63 deletions
|
|
@ -164,7 +164,8 @@ class DocumentParser @Inject constructor(
|
|||
) {
|
||||
readerText.add(
|
||||
0, ReaderText.Chapter(
|
||||
title = formattedLine.clearAllMarkdown()
|
||||
title = formattedLine.clearAllMarkdown(),
|
||||
nested = false
|
||||
)
|
||||
)
|
||||
chapterAdded = true
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import javax.inject.Inject
|
|||
import kotlin.collections.set
|
||||
|
||||
private const val EPUB_TAG = "EPUB Parser"
|
||||
private typealias Title = String
|
||||
private typealias Source = String
|
||||
|
||||
private val dispatcher = Dispatchers.IO.limitedParallelism(3)
|
||||
|
||||
|
|
@ -108,7 +108,7 @@ class EpubTextParser @Inject constructor(
|
|||
private suspend fun ZipFile.parseEpub(
|
||||
chapterEntries: List<ZipEntry>,
|
||||
imageEntries: List<ZipEntry>,
|
||||
chapterTitleEntries: Map<Title, List<String>>?
|
||||
chapterTitleEntries: Map<Source, ReaderText.Chapter>?
|
||||
): List<ReaderText> {
|
||||
|
||||
val readerText = mutableListOf<ReaderText>()
|
||||
|
|
@ -159,7 +159,7 @@ class EpubTextParser @Inject constructor(
|
|||
index: Int,
|
||||
entry: ZipEntry,
|
||||
imageEntries: List<ZipEntry>,
|
||||
chapterTitleMap: Map<Title, List<String>>?
|
||||
chapterTitleMap: Map<Source, ReaderText.Chapter>?
|
||||
) {
|
||||
// Getting all text
|
||||
val content = zip.getInputStream(entry).bufferedReader().use { it.readText() }
|
||||
|
|
@ -176,22 +176,24 @@ class EpubTextParser @Inject constructor(
|
|||
chapterSource = entry.name,
|
||||
chapterTitleMap = chapterTitleMap
|
||||
).apply {
|
||||
val chapterTitle = this ?: run {
|
||||
val chapter = this ?: run {
|
||||
val firstVisibleText = readerText.firstOrNull { line ->
|
||||
line is ReaderText.Text && line.line.text.containsVisibleText()
|
||||
} as? ReaderText.Text ?: return
|
||||
firstVisibleText.line.text
|
||||
|
||||
return@run ReaderText.Chapter(
|
||||
title = firstVisibleText.line.text,
|
||||
nested = false
|
||||
)
|
||||
}
|
||||
|
||||
readerText = readerText.dropWhile { line ->
|
||||
(line is ReaderText.Text && line.line.text.lowercase() == chapterTitle.lowercase())
|
||||
(line is ReaderText.Text && line.line.text.lowercase() == chapter.title.lowercase())
|
||||
}.toMutableList()
|
||||
|
||||
readerText.add(
|
||||
0,
|
||||
ReaderText.Chapter(
|
||||
title = chapterTitle
|
||||
)
|
||||
chapter
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +215,7 @@ class EpubTextParser @Inject constructor(
|
|||
*/
|
||||
private suspend fun ZipFile.getChapterTitleMapFromToc(
|
||||
tocEntry: ZipEntry?
|
||||
): Map<Title, List<String>>? {
|
||||
): Map<Source, ReaderText.Chapter>? {
|
||||
val tocContent = tocEntry?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
getInputStream(it)
|
||||
|
|
@ -222,18 +224,43 @@ class EpubTextParser @Inject constructor(
|
|||
val tocDocument = tocContent?.let { Jsoup.parse(it) }
|
||||
|
||||
if (tocDocument == null) return null
|
||||
var titleMap = mutableMapOf<Title, List<String>>()
|
||||
var titleMap = mutableMapOf<Source, ReaderText.Chapter>()
|
||||
|
||||
tocDocument.select("navPoint").forEach { navPoint ->
|
||||
val title = navPoint.selectFirst("navLabel > text")?.text()?.trim()
|
||||
?: return@forEach
|
||||
val title = navPoint.selectFirst("navLabel > text")?.text()
|
||||
.let { title ->
|
||||
if (title.isNullOrBlank()) return@forEach
|
||||
title.trim()
|
||||
}
|
||||
|
||||
val source = navPoint.selectFirst("content")?.attr("src")?.trim()
|
||||
.let {
|
||||
if (it == null) return@forEach
|
||||
Uri.parse(it).path ?: it
|
||||
.let { source ->
|
||||
if (source.isNullOrBlank()) return@forEach
|
||||
Uri.parse(source).path ?: source
|
||||
}.substringAfterLast(File.separator)
|
||||
|
||||
titleMap[source] = (titleMap[source] ?: emptyList()) + title
|
||||
val parent = navPoint.parent()
|
||||
.let { parent ->
|
||||
if (parent == null) return@let null
|
||||
if (!parent.tagName().equals("navPoint", ignoreCase = true)) return@let null
|
||||
|
||||
val parentSource = parent.selectFirst("content")?.attr("src")?.trim()
|
||||
.let { parentSource ->
|
||||
if (parentSource.isNullOrBlank()) return@forEach
|
||||
Uri.parse(parentSource).path ?: parentSource
|
||||
}.substringAfterLast(File.separator)
|
||||
if (parentSource == source) return@let null
|
||||
return@let parentSource
|
||||
}
|
||||
|
||||
val chapter = ReaderText.Chapter(
|
||||
title = titleMap[source]?.title.run {
|
||||
if (this == null) return@run title
|
||||
return@run "$this / $title"
|
||||
},
|
||||
nested = titleMap[source]?.nested ?: (parent != null)
|
||||
)
|
||||
titleMap[source] = chapter
|
||||
}
|
||||
|
||||
return titleMap
|
||||
|
|
@ -246,14 +273,12 @@ class EpubTextParser @Inject constructor(
|
|||
*/
|
||||
private fun getChapterTitleFromToc(
|
||||
chapterSource: String,
|
||||
chapterTitleMap: Map<String, List<String>>?
|
||||
): String? {
|
||||
chapterTitleMap: Map<Source, ReaderText.Chapter>?
|
||||
): ReaderText.Chapter? {
|
||||
if (chapterTitleMap.isNullOrEmpty()) return null
|
||||
return chapterTitleMap
|
||||
.getOrElse(chapterSource.substringAfterLast(File.separator)) { null }
|
||||
?.joinToString(separator = " / ")
|
||||
?.ifBlank { null }
|
||||
?.trim()
|
||||
return chapterTitleMap.getOrElse(chapterSource.substringAfterLast(File.separator)) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -125,7 +125,8 @@ class Fb2TextParser @Inject constructor(
|
|||
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
|
||||
readerText.add(
|
||||
0, ReaderText.Chapter(
|
||||
title = line.clearAllMarkdown()
|
||||
title = line.clearAllMarkdown(),
|
||||
nested = false
|
||||
)
|
||||
)
|
||||
chapterAdded = true
|
||||
|
|
|
|||
|
|
@ -128,7 +128,8 @@ class PdfTextParser @Inject constructor(
|
|||
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
|
||||
readerText.add(
|
||||
0, ReaderText.Chapter(
|
||||
title = line.clearAllMarkdown()
|
||||
title = line.clearAllMarkdown(),
|
||||
nested = false
|
||||
)
|
||||
)
|
||||
chapterAdded = true
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ class TxtTextParser @Inject constructor(
|
|||
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
|
||||
readerText.add(
|
||||
0, ReaderText.Chapter(
|
||||
title = line.clearAllMarkdown()
|
||||
title = line.clearAllMarkdown(),
|
||||
nested = false
|
||||
)
|
||||
)
|
||||
chapterAdded = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2025 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package ua.acclorite.book_story.domain.reader
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class ExpandableChapter(
|
||||
val parent: ReaderText.Chapter,
|
||||
val expanded: Boolean,
|
||||
val chapters: List<ReaderText.Chapter>?
|
||||
)
|
||||
|
|
@ -9,11 +9,16 @@ package ua.acclorite.book_story.domain.reader
|
|||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import java.util.UUID
|
||||
|
||||
@Immutable
|
||||
sealed class ReaderText {
|
||||
@Immutable
|
||||
data class Chapter(val title: String) : ReaderText()
|
||||
data class Chapter(
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val title: String,
|
||||
val nested: Boolean
|
||||
) : ReaderText()
|
||||
|
||||
@Immutable
|
||||
data class Text(val line: AnnotatedString) : ReaderText()
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import androidx.compose.material3.LocalTextStyle
|
|||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -54,7 +55,8 @@ fun ModalDrawerSelectableItem(
|
|||
.clickable(enabled = enabled) {
|
||||
onClick()
|
||||
}
|
||||
.padding(horizontal = 18.dp, vertical = 18.dp)
|
||||
.padding(horizontal = 18.dp, vertical = 18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides if (selected) MaterialTheme.colorScheme.onSecondaryContainer
|
||||
|
|
|
|||
|
|
@ -6,24 +6,32 @@
|
|||
|
||||
package ua.acclorite.book_story.presentation.reader
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowDropUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ua.acclorite.book_story.R
|
||||
import ua.acclorite.book_story.domain.reader.ExpandableChapter
|
||||
import ua.acclorite.book_story.domain.reader.ReaderText.Chapter
|
||||
import ua.acclorite.book_story.presentation.core.components.common.StyledText
|
||||
import ua.acclorite.book_story.presentation.core.components.modal_drawer.ModalDrawer
|
||||
import ua.acclorite.book_story.presentation.core.components.modal_drawer.ModalDrawerSelectableItem
|
||||
import ua.acclorite.book_story.presentation.core.components.modal_drawer.ModalDrawerTitleItem
|
||||
import ua.acclorite.book_story.presentation.core.util.calculateProgress
|
||||
import ua.acclorite.book_story.presentation.core.util.noRippleClickable
|
||||
import ua.acclorite.book_story.ui.reader.ReaderEvent
|
||||
import ua.acclorite.book_story.ui.theme.ExpandingTransition
|
||||
|
||||
@Composable
|
||||
fun ReaderChaptersDrawer(
|
||||
|
|
@ -34,15 +42,43 @@ fun ReaderChaptersDrawer(
|
|||
scrollToChapter: (ReaderEvent.OnScrollToChapter) -> Unit,
|
||||
dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit
|
||||
) {
|
||||
val currentChapterIndex = remember(chapters, currentChapter) {
|
||||
derivedStateOf {
|
||||
chapters.indexOf(currentChapter).takeIf { it != -1 } ?: 0
|
||||
val expandableChapters = remember(show, chapters, currentChapter) {
|
||||
mutableStateListOf<ExpandableChapter>().apply {
|
||||
var index = 0
|
||||
while (index < chapters.size) {
|
||||
val chapter = chapters.getOrNull(index) ?: continue
|
||||
when (chapter.nested) {
|
||||
false -> {
|
||||
val children = chapters.drop(index + 1).takeWhile { it.nested }
|
||||
add(
|
||||
ExpandableChapter(
|
||||
parent = chapter,
|
||||
expanded = chapter.id == currentChapter?.id ||
|
||||
children.any { it.id == currentChapter?.id },
|
||||
chapters = children.takeIf { it.isNotEmpty() }
|
||||
)
|
||||
)
|
||||
index += children.size + 1
|
||||
}
|
||||
|
||||
true -> {
|
||||
add(
|
||||
ExpandableChapter(
|
||||
parent = chapter.copy(nested = false),
|
||||
expanded = false,
|
||||
chapters = null
|
||||
)
|
||||
)
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModalDrawer(
|
||||
show = show,
|
||||
startIndex = currentChapterIndex.value,
|
||||
startIndex = chapters.indexOf(currentChapter).takeIf { it != -1 } ?: 0,
|
||||
onDismissRequest = { dismissDrawer(ReaderEvent.OnDismissDrawer) },
|
||||
header = {
|
||||
ModalDrawerTitleItem(
|
||||
|
|
@ -50,30 +86,88 @@ fun ReaderChaptersDrawer(
|
|||
)
|
||||
}
|
||||
) {
|
||||
itemsIndexed(chapters, key = { index, _ -> index }) { index, chapter ->
|
||||
val selected = rememberSaveable(index, currentChapterIndex) {
|
||||
index == currentChapterIndex.value
|
||||
expandableChapters.forEach { expandableChapter ->
|
||||
item {
|
||||
ModalDrawerSelectableItem(
|
||||
selected = expandableChapter.parent.id == currentChapter?.id,
|
||||
onClick = {
|
||||
scrollToChapter(
|
||||
ReaderEvent.OnScrollToChapter(
|
||||
chapter = expandableChapter.parent
|
||||
)
|
||||
)
|
||||
dismissDrawer(ReaderEvent.OnDismissDrawer)
|
||||
}
|
||||
) {
|
||||
StyledText(
|
||||
text = expandableChapter.parent.title,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
if (expandableChapter.parent == currentChapter) {
|
||||
Spacer(modifier = Modifier.width(18.dp))
|
||||
StyledText(text = "${currentChapterProgress.calculateProgress(0)}%")
|
||||
}
|
||||
|
||||
if (!expandableChapter.chapters.isNullOrEmpty()) {
|
||||
Spacer(modifier = Modifier.width(18.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ArrowDropUp,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.noRippleClickable {
|
||||
expandableChapters.indexOf(expandableChapter)
|
||||
.also { chapterIndex ->
|
||||
if (chapterIndex == -1) return@noRippleClickable
|
||||
expandableChapters[chapterIndex] =
|
||||
expandableChapter.copy(
|
||||
expanded = !expandableChapter.expanded
|
||||
)
|
||||
}
|
||||
}
|
||||
.rotate(
|
||||
animateFloatAsState(
|
||||
targetValue = if (expandableChapter.expanded) 0f else -180f
|
||||
).value
|
||||
),
|
||||
contentDescription = stringResource(
|
||||
id = if (expandableChapter.expanded) R.string.collapse_content_desc
|
||||
else R.string.expand_content_desc
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModalDrawerSelectableItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
scrollToChapter(
|
||||
ReaderEvent.OnScrollToChapter(
|
||||
chapter = chapter
|
||||
)
|
||||
)
|
||||
dismissDrawer(ReaderEvent.OnDismissDrawer)
|
||||
}
|
||||
) {
|
||||
StyledText(
|
||||
text = chapter.title,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1
|
||||
)
|
||||
if (selected) {
|
||||
Spacer(modifier = Modifier.width(18.dp))
|
||||
StyledText(text = "${currentChapterProgress.calculateProgress(1)}%")
|
||||
if (!expandableChapter.chapters.isNullOrEmpty()) {
|
||||
items(expandableChapter.chapters) { chapter ->
|
||||
ExpandingTransition(visible = expandableChapter.expanded) {
|
||||
ModalDrawerSelectableItem(
|
||||
selected = chapter.id == currentChapter?.id,
|
||||
onClick = {
|
||||
scrollToChapter(
|
||||
ReaderEvent.OnScrollToChapter(
|
||||
chapter = chapter
|
||||
)
|
||||
)
|
||||
dismissDrawer(ReaderEvent.OnDismissDrawer)
|
||||
}
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(18.dp))
|
||||
|
||||
StyledText(
|
||||
text = chapter.title,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
if (chapter == currentChapter) {
|
||||
Spacer(modifier = Modifier.width(18.dp))
|
||||
StyledText(text = "${currentChapterProgress.calculateProgress(0)}%")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,10 +49,12 @@ fun LazyItemScope.ReaderLayoutTextChapter(
|
|||
modifier = Modifier
|
||||
.padding(horizontal = sidePadding)
|
||||
.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.headlineMedium.copy(
|
||||
color = fontColor,
|
||||
textAlign = chapterTitleAlignment.textAlignment
|
||||
),
|
||||
style = (if (!chapter.nested) MaterialTheme.typography.headlineMedium
|
||||
else MaterialTheme.typography.headlineSmall)
|
||||
.copy(
|
||||
color = fontColor,
|
||||
textAlign = chapterTitleAlignment.textAlignment
|
||||
),
|
||||
highlightText = highlightedReading,
|
||||
highlightThickness = highlightedReadingThickness
|
||||
)
|
||||
|
|
|
|||
|
|
@ -480,5 +480,7 @@
|
|||
<string name="show_less_content_desc">Показати менше</string>
|
||||
<string name="show_more_content_desc">Показати більше</string>
|
||||
<string name="edit_content_desc">Редагувати поле</string>
|
||||
<string name="expand_content_desc">Розгорнути</string>
|
||||
<string name="collapse_content_desc">Згорнути</string>
|
||||
|
||||
</resources>
|
||||
|
|
@ -639,5 +639,7 @@
|
|||
<string name="show_less_content_desc">Show less</string>
|
||||
<string name="show_more_content_desc">Show more</string>
|
||||
<string name="edit_content_desc">Edit field</string>
|
||||
<string name="expand_content_desc">Expand</string>
|
||||
<string name="collapse_content_desc">Collapse</string>
|
||||
|
||||
</resources>
|
||||
Loading…
Add table
Add a link
Reference in a new issue