From cd18c430a5e3f710988ba583b4d3ab24057dd0eb Mon Sep 17 00:00:00 2001 From: Acclorite <140836141+Acclorite@users.noreply.github.com> Date: Mon, 10 Feb 2025 12:43:36 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20Nested=20Chapters=20in=20Reader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixed issue with incorrect navigation * Added Nested Chapters, e.g. Book -> Chapter Resolves: #130 --- .../book_story/data/parser/DocumentParser.kt | 3 +- .../data/parser/epub/EpubTextParser.kt | 73 ++++++--- .../data/parser/fb2/Fb2TextParser.kt | 3 +- .../data/parser/pdf/PdfTextParser.kt | 3 +- .../data/parser/txt/TxtTextParser.kt | 3 +- .../domain/reader/ExpandableChapter.kt | 16 ++ .../book_story/domain/reader/ReaderText.kt | 7 +- .../modal_drawer/ModalDrawerSelectableItem.kt | 4 +- .../reader/ReaderChaptersDrawer.kt | 152 ++++++++++++++---- .../reader/ReaderLayoutTextChapter.kt | 10 +- app/src/main/res/values-uk/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 12 files changed, 215 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/ua/acclorite/book_story/domain/reader/ExpandableChapter.kt diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/DocumentParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/DocumentParser.kt index c7505291..8e54dbf1 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/DocumentParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/DocumentParser.kt @@ -164,7 +164,8 @@ class DocumentParser @Inject constructor( ) { readerText.add( 0, ReaderText.Chapter( - title = formattedLine.clearAllMarkdown() + title = formattedLine.clearAllMarkdown(), + nested = false ) ) chapterAdded = true diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt index 68c34900..27701c1f 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt @@ -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, imageEntries: List, - chapterTitleEntries: Map>? + chapterTitleEntries: Map? ): List { val readerText = mutableListOf() @@ -159,7 +159,7 @@ class EpubTextParser @Inject constructor( index: Int, entry: ZipEntry, imageEntries: List, - chapterTitleMap: Map>? + chapterTitleMap: Map? ) { // 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>? { + ): Map? { 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>() + var titleMap = mutableMapOf() 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? { + chapterTitleMap: Map? + ): 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 + } } /** diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/fb2/Fb2TextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/fb2/Fb2TextParser.kt index a3a9670f..96b2f820 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/fb2/Fb2TextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/fb2/Fb2TextParser.kt @@ -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 diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt index a3acedf1..9ba7dfea 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt @@ -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 diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt index 2233cf37..dec6c109 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt @@ -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 diff --git a/app/src/main/java/ua/acclorite/book_story/domain/reader/ExpandableChapter.kt b/app/src/main/java/ua/acclorite/book_story/domain/reader/ExpandableChapter.kt new file mode 100644 index 00000000..02cb2f4c --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/reader/ExpandableChapter.kt @@ -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? +) \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/reader/ReaderText.kt b/app/src/main/java/ua/acclorite/book_story/domain/reader/ReaderText.kt index c691d691..0f18cd6d 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/reader/ReaderText.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/reader/ReaderText.kt @@ -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() diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/core/components/modal_drawer/ModalDrawerSelectableItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/core/components/modal_drawer/ModalDrawerSelectableItem.kt index 8210a285..d427d2fb 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/core/components/modal_drawer/ModalDrawerSelectableItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/core/components/modal_drawer/ModalDrawerSelectableItem.kt @@ -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 diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/reader/ReaderChaptersDrawer.kt b/app/src/main/java/ua/acclorite/book_story/presentation/reader/ReaderChaptersDrawer.kt index a8fe381e..66db4772 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/reader/ReaderChaptersDrawer.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/reader/ReaderChaptersDrawer.kt @@ -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().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)}%") + } + } + } } } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/reader/ReaderLayoutTextChapter.kt b/app/src/main/java/ua/acclorite/book_story/presentation/reader/ReaderLayoutTextChapter.kt index f23409fa..d6204a5d 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/reader/ReaderLayoutTextChapter.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/reader/ReaderLayoutTextChapter.kt @@ -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 ) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8b397fa7..d7d77a99 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -480,5 +480,7 @@ Показати менше Показати більше Редагувати поле + Розгорнути + Згорнути \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be39a936..691b3c77 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -639,5 +639,7 @@ Show less Show more Edit field + Expand + Collapse \ No newline at end of file