🚀 Reformat parsers

* Reformatted parsers
* Direct parsing
* Scalable
* Slightly faster parsing (no optimizations yet)

Part-of: #134
This commit is contained in:
Acclorite 2025-01-01 12:49:58 +02:00
parent 3853727ae7
commit fb39d4fe15
32 changed files with 594 additions and 528 deletions

View file

@ -1,9 +1,8 @@
package ua.acclorite.book_story.data.parser
import ua.acclorite.book_story.domain.reader.ChapterWithText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.reader.ReaderText
import java.io.File
interface TextParser {
suspend fun parse(file: File): Resource<List<ChapterWithText>>
suspend fun parse(file: File): List<ReaderText>
}

View file

@ -1,15 +1,12 @@
package ua.acclorite.book_story.data.parser
import android.util.Log
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.epub.EpubTextParser
import ua.acclorite.book_story.data.parser.fb2.Fb2TextParser
import ua.acclorite.book_story.data.parser.html.HtmlTextParser
import ua.acclorite.book_story.data.parser.pdf.PdfTextParser
import ua.acclorite.book_story.data.parser.txt.TxtTextParser
import ua.acclorite.book_story.domain.reader.ChapterWithText
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.reader.ReaderText
import java.io.File
import javax.inject.Inject
@ -20,14 +17,12 @@ class TextParserImpl @Inject constructor(
private val pdfTextParser: PdfTextParser,
private val epubTextParser: EpubTextParser,
private val fb2TextParser: Fb2TextParser,
private val htmlTextParser: HtmlTextParser,
private val htmlTextParser: HtmlTextParser
) : TextParser {
override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
override suspend fun parse(file: File): List<ReaderText> {
if (!file.exists()) {
Log.e(TEXT_PARSER, "File does not exist.")
return Resource.Error(
UIText.StringResource(R.string.error_something_went_wrong_with_file)
)
return emptyList()
}
val fileFormat = ".${file.extension}".lowercase().trim()
@ -66,7 +61,7 @@ class TextParserImpl @Inject constructor(
else -> {
Log.e(TEXT_PARSER, "Wrong file format, could not find supported extension.")
Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
emptyList()
}
}
}

View file

@ -12,14 +12,12 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.DocumentParser
import ua.acclorite.book_story.data.parser.MarkdownParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.ChapterWithText
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.presentation.core.util.addAll
import ua.acclorite.book_story.presentation.core.util.clearAllMarkdown
import ua.acclorite.book_story.presentation.core.util.clearMarkdown
import java.io.File
import java.util.concurrent.ConcurrentLinkedQueue
@ -34,16 +32,16 @@ private typealias Title = String
private val dispatcher = Dispatchers.IO.limitedParallelism(2)
class EpubTextParser @Inject constructor(
private val markdownParser: MarkdownParser,
private val documentParser: DocumentParser
) : TextParser {
override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
override suspend fun parse(file: File): List<ReaderText> {
Log.i(EPUB_TAG, "Started EPUB parsing: ${file.name}.")
return try {
val chapters = mutableListOf<ChapterWithText>()
yield()
var readerText = listOf<ReaderText>()
withContext(Dispatchers.IO) {
ZipFile(file).use { zip ->
@ -62,35 +60,28 @@ class EpubTextParser @Inject constructor(
Log.i(EPUB_TAG, "Chapter entries, size: ${chapterEntries.size}")
Log.i(EPUB_TAG, "Title entries, size: ${chapterTitleEntries?.size}")
zip.parseEpub(
readerText = zip.parseEpub(
chapterEntries = chapterEntries,
chapterTitleEntries = chapterTitleEntries
).let {
if (it == null || it.isEmpty()) {
Log.e(EPUB_TAG, "Could not parse EPUB (null or empty).")
return@withContext
}
chapters.addAll(it)
}
)
}
}
yield()
if (chapters.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
if (
readerText.filterIsInstance<ReaderText.Text>().isEmpty() ||
readerText.filterIsInstance<ReaderText.Chapter>().isEmpty()
) {
Log.e(EPUB_TAG, "Could not extract text from EPUB.")
return emptyList()
}
Log.i(EPUB_TAG, "Successfully finished EPUB parsing.")
Resource.Success(chapters)
readerText
} catch (e: Exception) {
e.printStackTrace()
Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""
)
)
emptyList()
}
}
@ -107,18 +98,18 @@ class EpubTextParser @Inject constructor(
private suspend fun ZipFile.parseEpub(
chapterEntries: List<ZipEntry>,
chapterTitleEntries: Map<Title, List<String>>?
): List<ChapterWithText>? {
): List<ReaderText> {
val chapters = mutableListOf<ChapterWithText>()
val readerText = mutableListOf<ReaderText>()
coroutineScope {
val unformattedChapters = ConcurrentLinkedQueue<ChapterWithText>()
val unformattedText = ConcurrentLinkedQueue<Pair<Int, List<ReaderText>>>()
// Asynchronously getting all chapters with text
val jobs = chapterEntries.mapIndexed { index, entry ->
async(dispatcher) {
yield()
unformattedChapters.parseZipEntry(
unformattedText.parseZipEntry(
zip = this@parseEpub,
index = index,
entry = entry,
@ -131,27 +122,15 @@ class EpubTextParser @Inject constructor(
jobs.awaitAll()
// Sorting chapters in correct order
chapters.addAll {
var textIndex = -1
unformattedChapters.toList()
.sortedBy { it.chapter.index }
.mapIndexed { index, item ->
item.copy(
chapter = item.chapter.copy(
index = index,
startIndex = textIndex + 1,
endIndex = textIndex + item.text.size
)
).also { textIndex += item.text.size }
}
readerText.addAll {
unformattedText.toList()
.sortedBy { (index, _) -> index }
.map { it.second }
.flatten()
}
}
if (chapters.isEmpty()) {
return null
}
return chapters
return readerText
}
/**
@ -163,7 +142,7 @@ class EpubTextParser @Inject constructor(
* @param entry [ZipEntry].
* @param chapterTitleMap Titles from [getChapterTitleMapFromToc].
*/
private suspend fun ConcurrentLinkedQueue<ChapterWithText>.parseZipEntry(
private suspend fun ConcurrentLinkedQueue<Pair<Int, List<ReaderText>>>.parseZipEntry(
zip: ZipFile,
index: Int,
entry: ZipEntry,
@ -171,46 +150,67 @@ class EpubTextParser @Inject constructor(
) {
// Getting all text
val content = zip.getInputStream(entry).bufferedReader().use { it.readText() }
var chapter = documentParser.run {
var text = documentParser.run {
Jsoup.parse(content).parseDocument()
}
val readerText = mutableListOf<ReaderText>()
if (chapter.isEmpty()) {
Log.w(EPUB_TAG, "Chapter ${entry.name} is empty.")
return
}
// Getting title and removing first line (if matches title)
val chapterTitle = getChapterTitleFromToc(
// Adding chapter title from TOC if found
var chapterAdded = false
getChapterTitleFromToc(
chapterSource = entry.name,
chapterTitleMap = chapterTitleMap
).run {
if (this != null) {
return@run this
}
chapter.first().clearMarkdown()
}.also { title ->
chapter = chapter.dropWhile { line ->
line.clearMarkdown().lowercase() == title.lowercase()
).apply {
if (this == null) return@apply
readerText.add(
ReaderText.Chapter(
title = this
)
)
chapterAdded = true
text = text.dropWhile { line ->
line.clearMarkdown().lowercase() == this.lowercase()
}
}
if (chapter.isEmpty()) {
Log.w(EPUB_TAG, "Chapter ${entry.name} is empty.")
// Format and add text
text.forEach { line ->
yield()
if (line.isNotBlank()) {
when (line) {
"***", "---" -> readerText.add(
ReaderText.Separator
)
else -> {
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
readerText.add(
0, ReaderText.Chapter(
title = line.clearAllMarkdown()
)
)
chapterAdded = true
} else readerText.add(
ReaderText.Text(
line = markdownParser.parse(line)
)
)
}
}
}
}
if (
readerText.filterIsInstance<ReaderText.Text>().isEmpty() ||
readerText.filterIsInstance<ReaderText.Chapter>().isEmpty()
) {
Log.w(EPUB_TAG, "Could not extract text from [${entry.name}].")
return
}
add(
ChapterWithText(
Chapter(
index = index,
title = chapterTitle,
startIndex = 0,
endIndex = 0
),
text = chapter
)
)
add(index to readerText)
}
/**

View file

@ -6,12 +6,9 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.w3c.dom.Element
import org.w3c.dom.NodeList
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.MarkdownParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.ChapterWithText
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.presentation.core.util.clearAllMarkdown
import java.io.File
import javax.inject.Inject
@ -19,9 +16,11 @@ import javax.xml.parsers.DocumentBuilderFactory
private const val FB2_TAG = "FB2 Parser"
class Fb2TextParser @Inject constructor() : TextParser {
class Fb2TextParser @Inject constructor(
private val markdownParser: MarkdownParser
) : TextParser {
override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
override suspend fun parse(file: File): List<ReaderText> {
Log.i(FB2_TAG, "Started FB2 parsing: ${file.name}.")
return try {
@ -31,13 +30,11 @@ class Fb2TextParser @Inject constructor() : TextParser {
builder.parse(file)
}
val formattedLines = mutableListOf<String>()
val readerText = mutableListOf<ReaderText>()
val bodyNodes = document.getElementsByTagName("body")
if (bodyNodes.length == 0) {
return Resource.Error(
UIText.StringResource(R.string.error_file_empty)
)
return emptyList()
}
yield()
@ -108,45 +105,49 @@ class Fb2TextParser @Inject constructor() : TextParser {
yield()
var chapterAdded = false
lines.forEach { line ->
yield()
formattedLines.add(line.trim())
if (line.isNotBlank()) {
when (line) {
"***", "---" -> readerText.add(
ReaderText.Separator
)
else -> {
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
readerText.add(
0, ReaderText.Chapter(
title = line.clearAllMarkdown()
)
)
chapterAdded = true
} else readerText.add(
ReaderText.Text(
line = markdownParser.parse(line)
)
)
}
}
}
}
yield()
if (formattedLines.size < 2) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
val title = formattedLines.first().clearAllMarkdown().let { title ->
formattedLines.removeAt(0)
if (title.isBlank()) return@let "Chapter 1"
return@let title
if (
readerText.filterIsInstance<ReaderText.Text>().isEmpty() ||
readerText.filterIsInstance<ReaderText.Chapter>().isEmpty()
) {
Log.e(FB2_TAG, "Could not extract text from FB2.")
return emptyList()
}
Log.i(FB2_TAG, "Successfully finished FB2 parsing.")
Resource.Success(
listOf(
ChapterWithText(
chapter = Chapter(
index = 0,
title = title,
startIndex = 0,
endIndex = formattedLines.lastIndex
),
text = formattedLines
)
)
)
readerText
} catch (e: Exception) {
e.printStackTrace()
Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""
)
)
emptyList()
}
}

View file

@ -3,13 +3,10 @@ package ua.acclorite.book_story.data.parser.html
import android.util.Log
import kotlinx.coroutines.yield
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.DocumentParser
import ua.acclorite.book_story.data.parser.MarkdownParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.ChapterWithText
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.presentation.core.util.clearAllMarkdown
import java.io.File
import javax.inject.Inject
@ -17,52 +14,62 @@ import javax.inject.Inject
private const val HTML_TAG = "HTML Parser"
class HtmlTextParser @Inject constructor(
private val markdownParser: MarkdownParser,
private val documentParser: DocumentParser
) : TextParser {
override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
override suspend fun parse(file: File): List<ReaderText> {
Log.i(HTML_TAG, "Started HTML parsing: ${file.name}.")
return try {
val lines = documentParser.run {
var chapterAdded = false
val documentLines = documentParser.run {
Jsoup.parse(file).parseDocument()
}.toMutableList()
}
val readerText = mutableListOf<ReaderText>()
for (line in documentLines) {
yield()
if (line.isNotBlank()) {
when (line) {
"***", "---" -> readerText.add(
ReaderText.Separator
)
else -> {
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
readerText.add(
0, ReaderText.Chapter(
title = line.clearAllMarkdown()
)
)
chapterAdded = true
} else readerText.add(
ReaderText.Text(
line = markdownParser.parse(line)
)
)
}
}
}
}
yield()
if (lines.size < 2) {
if (
readerText.filterIsInstance<ReaderText.Text>().isEmpty() ||
readerText.filterIsInstance<ReaderText.Chapter>().isEmpty()
) {
Log.e(HTML_TAG, "Could not extract text from HTML.")
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
val title = lines.first().clearAllMarkdown().let { title ->
lines.removeAt(0)
if (title.isBlank()) return@let "Chapter 1"
return@let title
return emptyList()
}
Log.i(HTML_TAG, "Successfully finished HTML parsing.")
Resource.Success(
listOf(
ChapterWithText(
chapter = Chapter(
index = 0,
title = title,
startIndex = 0,
endIndex = lines.lastIndex
),
text = lines
)
)
)
readerText
} catch (e: Exception) {
e.printStackTrace()
Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""
)
)
emptyList()
}
}
}

View file

@ -6,12 +6,9 @@ import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.text.PDFTextStripper
import kotlinx.coroutines.yield
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.MarkdownParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.ChapterWithText
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.presentation.core.util.clearAllMarkdown
import java.io.File
import javax.inject.Inject
@ -19,10 +16,11 @@ import javax.inject.Inject
private const val PDF_TAG = "PDF Parser"
class PdfTextParser @Inject constructor(
private val markdownParser: MarkdownParser,
private val application: Application
) : TextParser {
override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
override suspend fun parse(file: File): List<ReaderText> {
Log.i(PDF_TAG, "Started PDF parsing: ${file.name}.")
return try {
@ -44,7 +42,7 @@ class PdfTextParser @Inject constructor(
yield()
val strings = mutableListOf<String>()
val readerText = mutableListOf<ReaderText>()
val text = oldText.filterIndexed { index, c ->
yield()
@ -110,45 +108,49 @@ class PdfTextParser @Inject constructor(
yield()
var chapterAdded = false
lines.forEach { line ->
yield()
strings.add(line.trim())
if (line.isNotBlank()) {
when (line) {
"***", "---" -> readerText.add(
ReaderText.Separator
)
else -> {
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
readerText.add(
0, ReaderText.Chapter(
title = line.clearAllMarkdown()
)
)
chapterAdded = true
} else readerText.add(
ReaderText.Text(
line = markdownParser.parse(line)
)
)
}
}
}
}
yield()
if (strings.size < 2) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
val title = strings.first().clearAllMarkdown().let { title ->
strings.removeAt(0)
if (title.isBlank()) return@let "Chapter 1"
return@let title
if (
readerText.filterIsInstance<ReaderText.Text>().isEmpty() ||
readerText.filterIsInstance<ReaderText.Chapter>().isEmpty()
) {
Log.e(PDF_TAG, "Could not extract text from PDF.")
return emptyList()
}
Log.i(PDF_TAG, "Successfully finished PDF parsing.")
Resource.Success(
listOf(
ChapterWithText(
chapter = Chapter(
index = 0,
title = title,
startIndex = 0,
endIndex = strings.lastIndex
),
text = strings
)
)
)
readerText
} catch (e: Exception) {
e.printStackTrace()
Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""
)
)
emptyList()
}
}
}

View file

@ -4,12 +4,9 @@ import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.MarkdownParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.ChapterWithText
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.presentation.core.util.clearAllMarkdown
import java.io.BufferedReader
import java.io.File
@ -18,58 +15,59 @@ import javax.inject.Inject
private const val TXT_TAG = "TXT Parser"
class TxtTextParser @Inject constructor() : TextParser {
class TxtTextParser @Inject constructor(
private val markdownParser: MarkdownParser
) : TextParser {
override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
override suspend fun parse(file: File): List<ReaderText> {
Log.i(TXT_TAG, "Started TXT parsing: ${file.name}.")
return try {
val lines = mutableListOf<String>()
val readerText = mutableListOf<ReaderText>()
var chapterAdded = false
withContext(Dispatchers.IO) {
BufferedReader(FileReader(file)).forEachLine { line ->
if (line.isNotBlank()) {
lines.add(
line.trim()
)
when (line) {
"***", "---" -> readerText.add(
ReaderText.Separator
)
else -> {
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
readerText.add(
0, ReaderText.Chapter(
title = line.clearAllMarkdown()
)
)
chapterAdded = true
} else readerText.add(
ReaderText.Text(
line = markdownParser.parse(line)
)
)
}
}
}
}
}
yield()
if (lines.size < 2) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
val title = lines.first().clearAllMarkdown().let { title ->
lines.removeAt(0)
if (title.isBlank()) return@let "Chapter 1"
return@let title
if (
readerText.filterIsInstance<ReaderText.Text>().isEmpty() ||
readerText.filterIsInstance<ReaderText.Chapter>().isEmpty()
) {
Log.e(TXT_TAG, "Could not extract text from TXT.")
return emptyList()
}
Log.i(TXT_TAG, "Successfully finished TXT parsing.")
Resource.Success(
listOf(
ChapterWithText(
chapter = Chapter(
index = 0,
title = title,
startIndex = 0,
endIndex = lines.lastIndex
),
text = lines
)
)
)
readerText
} catch (e: Exception) {
e.printStackTrace()
Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""
)
)
emptyList()
}
}
}

View file

@ -6,20 +6,17 @@ import android.graphics.BitmapFactory
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import androidx.compose.ui.text.AnnotatedString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ua.acclorite.book_story.data.local.room.BookDao
import ua.acclorite.book_story.data.mapper.book.BookMapper
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.data.parser.MarkdownParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.book.BookWithCover
import ua.acclorite.book_story.domain.reader.ChaptersAndText
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.Resource
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
@ -45,8 +42,7 @@ class BookRepositoryImpl @Inject constructor(
private val bookMapper: BookMapper,
private val fileParser: FileParser,
private val textParser: TextParser,
private val markdownParser: MarkdownParser
private val textParser: TextParser
) : BookRepository {
/**
@ -90,43 +86,31 @@ class BookRepositoryImpl @Inject constructor(
}
/**
* Loads text from the book.
* Loads text from the book. Already formatted.
*/
override suspend fun getBookText(bookId: Int): ChaptersAndText {
if (bookId == -1) return ChaptersAndText(chapters = emptyList(), text = emptyList())
override suspend fun getBookText(bookId: Int): List<ReaderText> {
if (bookId == -1) return emptyList()
val book = database.findBookById(bookId)
val file = File(book.filePath)
if (!file.exists()) {
Log.e(GET_TEXT, "File [$bookId] does not exist")
return ChaptersAndText(chapters = emptyList(), text = emptyList())
return emptyList()
}
val parsedText = textParser.parse(file)
if (parsedText is Resource.Error) {
Log.e(GET_TEXT, "Failed to load text: $bookId")
return ChaptersAndText(chapters = emptyList(), text = emptyList())
}
val readerText = textParser.parse(file)
val chapters = parsedText.data!!.map { it.chapter }
val markdownLines = mutableListOf<AnnotatedString>()
withContext(Dispatchers.IO) {
for (line in parsedText.data.map { it.text }.flatten()) {
if (line.isNotBlank()) {
markdownLines.add(
markdownParser.parse(line.trim())
)
}
}
if (
readerText.filterIsInstance<ReaderText.Text>().isEmpty() ||
readerText.filterIsInstance<ReaderText.Chapter>().isEmpty()
) {
Log.e(GET_TEXT, "Could not load text from [$bookId].")
return emptyList()
}
Log.i(GET_TEXT, "Successfully loaded text of [$bookId] with markdown.")
return ChaptersAndText(
chapters = chapters,
text = markdownLines
)
return readerText
}
/**

View file

@ -1,14 +0,0 @@
package ua.acclorite.book_story.domain.reader
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize
@Parcelize
@Immutable
data class Chapter(
val index: Int = 0,
val title: String,
val startIndex: Int,
val endIndex: Int
) : Parcelable

View file

@ -1,9 +0,0 @@
package ua.acclorite.book_story.domain.reader
import androidx.compose.runtime.Immutable
@Immutable
data class ChapterWithText(
val chapter: Chapter,
val text: List<String>
)

View file

@ -1,10 +0,0 @@
package ua.acclorite.book_story.domain.reader
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
@Immutable
data class ChaptersAndText(
val chapters: List<Chapter>,
val text: List<AnnotatedString>
)

View file

@ -0,0 +1,16 @@
package ua.acclorite.book_story.domain.reader
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
@Immutable
sealed class ReaderText {
@Immutable
data class Chapter(val title: String) : ReaderText()
@Immutable
data class Text(val line: AnnotatedString) : ReaderText()
@Immutable
data object Separator : ReaderText()
}

View file

@ -2,7 +2,7 @@ package ua.acclorite.book_story.domain.repository
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.book.BookWithCover
import ua.acclorite.book_story.domain.reader.ChaptersAndText
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.domain.util.CoverImage
interface BookRepository {
@ -17,7 +17,7 @@ interface BookRepository {
suspend fun getBookText(
bookId: Int
): ChaptersAndText
): List<ReaderText>
suspend fun insertBook(
bookWithCover: BookWithCover

View file

@ -1,6 +1,6 @@
package ua.acclorite.book_story.domain.use_case.book
import ua.acclorite.book_story.domain.reader.ChaptersAndText
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Inject
@ -8,7 +8,7 @@ class GetText @Inject constructor(
private val repository: BookRepository
) {
suspend fun execute(bookId: Int): ChaptersAndText {
suspend fun execute(bookId: Int): List<ReaderText> {
return repository.getBookText(bookId = bookId)
}
}

View file

@ -1,8 +0,0 @@
package ua.acclorite.book_story.domain.util
import ua.acclorite.book_story.domain.ui.UIText
sealed class Resource<T>(val data: T? = null, val message: UIText? = null) {
class Success<T>(data: T?) : Resource<T>(data)
class Error<T>(message: UIText, data: T? = null) : Resource<T>(data, message)
}

View file

@ -18,7 +18,8 @@ fun Float.calculateProgress(digits: Int): String {
.dropWhile { it == '-' }
}
fun Float.coerceAndPreventNaN(): Float {
fun Float?.coerceAndPreventNaN(): Float {
if (this == null) return 0f
if (isNaN()) return 0f
return this.coerceIn(0f, 1f)
}

View file

@ -22,13 +22,13 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.Checkpoint
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.domain.reader.ReaderText.Chapter
import ua.acclorite.book_story.domain.util.Direction
import ua.acclorite.book_story.presentation.core.components.common.IconButton
import ua.acclorite.book_story.presentation.core.util.calculateProgress
@ -40,7 +40,7 @@ import ua.acclorite.book_story.ui.theme.HorizontalExpandingTransition
@Composable
fun ReaderBottomBar(
book: Book,
text: List<AnnotatedString>,
text: List<ReaderText>,
listState: LazyListState,
lockMenu: Boolean,
currentChapter: Chapter?,

View file

@ -2,16 +2,18 @@ package ua.acclorite.book_story.presentation.reader
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.ReaderText.Chapter
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
@ -27,9 +29,15 @@ fun ReaderChaptersDrawer(
scrollToChapter: (ReaderEvent.OnScrollToChapter) -> Unit,
dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit
) {
val currentChapterIndex = remember(chapters, currentChapter) {
derivedStateOf {
chapters.indexOf(currentChapter).takeIf { it != -1 } ?: 0
}
}
ModalDrawer(
show = show,
startIndex = currentChapter?.index ?: 0,
startIndex = currentChapterIndex.value,
onDismissRequest = { dismissDrawer(ReaderEvent.OnDismissDrawer) },
header = {
ModalDrawerTitleItem(
@ -37,9 +45,9 @@ fun ReaderChaptersDrawer(
)
}
) {
items(chapters, key = { it.index }) { chapter ->
val selected = rememberSaveable(currentChapter) {
chapter.index == currentChapter?.index
itemsIndexed(chapters, key = { index, _ -> index }) { index, chapter ->
val selected = rememberSaveable(index, currentChapterIndex) {
index == currentChapterIndex.value
}
ModalDrawerSelectableItem(
@ -47,7 +55,7 @@ fun ReaderChaptersDrawer(
onClick = {
scrollToChapter(
ReaderEvent.OnScrollToChapter(
chapterStartIndex = chapter.startIndex
chapter = chapter
)
)
dismissDrawer(ReaderEvent.OnDismissDrawer)

View file

@ -3,17 +3,18 @@ package ua.acclorite.book_story.presentation.reader
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.Checkpoint
import ua.acclorite.book_story.domain.reader.FontWithName
import ua.acclorite.book_story.domain.reader.ReaderHorizontalGesture
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.domain.reader.ReaderText.Chapter
import ua.acclorite.book_story.domain.reader.ReaderTextAlignment
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.domain.util.BottomSheet
@ -24,7 +25,7 @@ import ua.acclorite.book_story.ui.settings.SettingsEvent
@Composable
fun ReaderContent(
book: Book,
text: List<AnnotatedString>,
text: List<ReaderText>,
bottomSheet: BottomSheet?,
drawer: Drawer?,
listState: LazyListState,
@ -40,7 +41,6 @@ fun ReaderContent(
checkpoint: Checkpoint,
showMenu: Boolean,
lockMenu: Boolean,
chapters: Map<Int, Chapter>,
contentPadding: PaddingValues,
verticalPadding: Dp,
horizontalGesture: ReaderHorizontalGesture,
@ -102,7 +102,6 @@ fun ReaderContent(
checkpoint = checkpoint,
showMenu = showMenu,
lockMenu = lockMenu,
chapters = chapters,
contentPadding = contentPadding,
verticalPadding = verticalPadding,
horizontalGesture = horizontalGesture,
@ -141,7 +140,7 @@ fun ReaderContent(
ReaderDrawer(
drawer = drawer,
chapters = chapters.values.toList(),
chapters = remember(text) { text.filterIsInstance<Chapter>() },
currentChapter = currentChapter,
currentChapterProgress = currentChapterProgress,
scrollToChapter = scrollToChapter,

View file

@ -1,7 +1,7 @@
package ua.acclorite.book_story.presentation.reader
import androidx.compose.runtime.Composable
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.ReaderText.Chapter
import ua.acclorite.book_story.domain.util.Drawer
import ua.acclorite.book_story.ui.reader.ReaderEvent
import ua.acclorite.book_story.ui.reader.ReaderScreen

View file

@ -14,16 +14,15 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.FontWithName
import ua.acclorite.book_story.domain.reader.ReaderHorizontalGesture
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.domain.reader.ReaderTextAlignment
import ua.acclorite.book_story.presentation.core.components.common.LazyColumnWithScrollbar
import ua.acclorite.book_story.presentation.core.components.common.SelectionContainer
@ -34,8 +33,7 @@ import ua.acclorite.book_story.ui.reader.ReaderEvent
@Composable
fun ReaderLayout(
text: List<AnnotatedString>,
chapters: Map<Int, Chapter>,
text: List<ReaderText>,
listState: LazyListState,
contentPadding: PaddingValues,
verticalPadding: Dp,
@ -148,17 +146,11 @@ fun ReaderLayout(
) {
itemsIndexed(
text, key = { index, _ -> index }
) { index, line ->
ReaderLayoutChapter(
chapter = chapters[index], // Shows only when matching startIndex of chapter.
fontColor = fontColor,
sidePadding = sidePadding
)
ReaderLayoutParagraph(
) { index, textEntry ->
ReaderLayoutText(
activity = activity,
showMenu = showMenu,
line = line,
textEntry = textEntry,
fontFamily = fontFamily,
fontColor = fontColor,
lineHeight = lineHeight,

View file

@ -1,120 +0,0 @@
package ua.acclorite.book_story.presentation.reader
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.reader.FontWithName
import ua.acclorite.book_story.domain.reader.ReaderTextAlignment
import ua.acclorite.book_story.presentation.core.util.noRippleClickable
import ua.acclorite.book_story.ui.reader.ReaderEvent
@Composable
fun LazyItemScope.ReaderLayoutParagraph(
activity: ComponentActivity,
showMenu: Boolean,
line: AnnotatedString,
fontFamily: FontWithName,
fontColor: Color,
lineHeight: TextUnit,
fontStyle: FontStyle,
textAlignment: ReaderTextAlignment,
fontSize: TextUnit,
letterSpacing: TextUnit,
sidePadding: Dp,
paragraphIndentation: TextUnit,
fullscreenMode: Boolean,
doubleClickTranslation: Boolean,
toolbarHidden: Boolean,
openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit,
menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit
) {
Column(
modifier = Modifier
.animateItem(fadeInSpec = null, fadeOutSpec = null)
.fillMaxWidth()
.padding(horizontal = sidePadding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = when (textAlignment) {
ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start
ReaderTextAlignment.CENTER -> Alignment.CenterHorizontally
ReaderTextAlignment.END -> Alignment.End
}
) {
when (line.text) {
"---" -> {
HorizontalDivider(
thickness = 3.dp,
modifier = Modifier.clip(CircleShape),
color = fontColor.copy(0.3f)
)
}
else -> {
BasicText(
text = line,
modifier = Modifier.then(
if (doubleClickTranslation && toolbarHidden) {
Modifier.noRippleClickable(
onDoubleClick = {
openTranslator(
ReaderEvent.OnOpenTranslator(
textToTranslate = line.text,
translateWholeParagraph = true,
activity = activity
)
)
},
onClick = {
menuVisibility(
ReaderEvent.OnMenuVisibility(
show = !showMenu,
fullscreenMode = fullscreenMode,
saveCheckpoint = true,
activity = activity
)
)
}
)
} else Modifier
),
style = TextStyle(
fontFamily = fontFamily.font,
textAlign = when (textAlignment) {
ReaderTextAlignment.START -> TextAlign.Start
ReaderTextAlignment.JUSTIFY -> TextAlign.Justify
ReaderTextAlignment.CENTER -> TextAlign.Center
ReaderTextAlignment.END -> TextAlign.End
},
textIndent = TextIndent(firstLine = paragraphIndentation),
fontStyle = fontStyle,
letterSpacing = letterSpacing,
fontSize = fontSize,
lineHeight = lineHeight,
color = fontColor,
lineBreak = LineBreak.Paragraph
)
)
}
}
}
}

View file

@ -0,0 +1,73 @@
package ua.acclorite.book_story.presentation.reader
import androidx.activity.ComponentActivity
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import ua.acclorite.book_story.domain.reader.FontWithName
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.domain.reader.ReaderTextAlignment
import ua.acclorite.book_story.ui.reader.ReaderEvent
@Composable
fun LazyItemScope.ReaderLayoutText(
activity: ComponentActivity,
showMenu: Boolean,
textEntry: ReaderText,
fontFamily: FontWithName,
fontColor: Color,
lineHeight: TextUnit,
fontStyle: FontStyle,
textAlignment: ReaderTextAlignment,
fontSize: TextUnit,
letterSpacing: TextUnit,
sidePadding: Dp,
paragraphIndentation: TextUnit,
fullscreenMode: Boolean,
doubleClickTranslation: Boolean,
toolbarHidden: Boolean,
openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit,
menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit
) {
when (textEntry) {
is ReaderText.Separator -> {
ReaderLayoutTextSeparator(
sidePadding = sidePadding,
fontColor = fontColor
)
}
is ReaderText.Chapter -> {
ReaderLayoutTextChapter(
chapter = textEntry,
fontColor = fontColor,
sidePadding = sidePadding
)
}
is ReaderText.Text -> {
ReaderLayoutTextParagraph(
paragraph = textEntry,
activity = activity,
showMenu = showMenu,
fontFamily = fontFamily,
fontColor = fontColor,
lineHeight = lineHeight,
fontStyle = fontStyle,
textAlignment = textAlignment,
fontSize = fontSize,
letterSpacing = letterSpacing,
sidePadding = sidePadding,
paragraphIndentation = paragraphIndentation,
fullscreenMode = fullscreenMode,
doubleClickTranslation = doubleClickTranslation,
toolbarHidden = toolbarHidden,
openTranslator = openTranslator,
menuVisibility = menuVisibility
)
}
}
}

View file

@ -11,24 +11,22 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.ReaderText.Chapter
@Composable
fun ReaderLayoutChapter(
chapter: Chapter?,
fun ReaderLayoutTextChapter(
chapter: Chapter,
fontColor: Color,
sidePadding: Dp
) {
chapter?.let {
Spacer(modifier = Modifier.height(22.dp))
Text(
text = chapter.title,
style = MaterialTheme.typography.headlineMedium,
color = fontColor,
modifier = Modifier.padding(horizontal = sidePadding)
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = fontColor.copy(0.4f))
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.height(22.dp))
Text(
text = chapter.title,
style = MaterialTheme.typography.headlineMedium,
color = fontColor,
modifier = Modifier.padding(horizontal = sidePadding)
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = fontColor.copy(0.4f))
Spacer(modifier = Modifier.height(16.dp))
}

View file

@ -0,0 +1,104 @@
package ua.acclorite.book_story.presentation.reader
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import ua.acclorite.book_story.domain.reader.FontWithName
import ua.acclorite.book_story.domain.reader.ReaderText.Text
import ua.acclorite.book_story.domain.reader.ReaderTextAlignment
import ua.acclorite.book_story.presentation.core.util.noRippleClickable
import ua.acclorite.book_story.ui.reader.ReaderEvent
@Composable
fun LazyItemScope.ReaderLayoutTextParagraph(
paragraph: Text,
activity: ComponentActivity,
showMenu: Boolean,
fontFamily: FontWithName,
fontColor: Color,
lineHeight: TextUnit,
fontStyle: FontStyle,
textAlignment: ReaderTextAlignment,
fontSize: TextUnit,
letterSpacing: TextUnit,
sidePadding: Dp,
paragraphIndentation: TextUnit,
fullscreenMode: Boolean,
doubleClickTranslation: Boolean,
toolbarHidden: Boolean,
openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit,
menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit
) {
Column(
modifier = Modifier
.animateItem(fadeInSpec = null, fadeOutSpec = null)
.fillMaxWidth()
.padding(horizontal = sidePadding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = when (textAlignment) {
ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start
ReaderTextAlignment.CENTER -> Alignment.CenterHorizontally
ReaderTextAlignment.END -> Alignment.End
}
) {
BasicText(
text = paragraph.line,
modifier = Modifier.then(
if (doubleClickTranslation && toolbarHidden) {
Modifier.noRippleClickable(
onDoubleClick = {
openTranslator(
ReaderEvent.OnOpenTranslator(
textToTranslate = paragraph.line.text,
translateWholeParagraph = true,
activity = activity
)
)
},
onClick = {
menuVisibility(
ReaderEvent.OnMenuVisibility(
show = !showMenu,
fullscreenMode = fullscreenMode,
saveCheckpoint = true,
activity = activity
)
)
}
)
} else Modifier
),
style = TextStyle(
fontFamily = fontFamily.font,
textAlign = when (textAlignment) {
ReaderTextAlignment.START -> TextAlign.Start
ReaderTextAlignment.JUSTIFY -> TextAlign.Justify
ReaderTextAlignment.CENTER -> TextAlign.Center
ReaderTextAlignment.END -> TextAlign.End
},
textIndent = TextIndent(firstLine = paragraphIndentation),
fontStyle = fontStyle,
letterSpacing = letterSpacing,
fontSize = fontSize,
lineHeight = lineHeight,
color = fontColor,
lineBreak = LineBreak.Paragraph
)
)
}
}

View file

@ -0,0 +1,25 @@
package ua.acclorite.book_story.presentation.reader
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun ReaderLayoutTextSeparator(
sidePadding: Dp,
fontColor: Color
) {
HorizontalDivider(
thickness = 3.dp,
modifier = Modifier
.padding(horizontal = sidePadding)
.clip(CircleShape),
color = fontColor.copy(0.3f)
)
}

View file

@ -14,15 +14,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.Checkpoint
import ua.acclorite.book_story.domain.reader.FontWithName
import ua.acclorite.book_story.domain.reader.ReaderHorizontalGesture
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.domain.reader.ReaderText.Chapter
import ua.acclorite.book_story.domain.reader.ReaderTextAlignment
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.presentation.core.components.common.AnimatedVisibility
@ -33,7 +33,7 @@ import ua.acclorite.book_story.ui.settings.SettingsEvent
@Composable
fun ReaderScaffold(
book: Book,
text: List<AnnotatedString>,
text: List<ReaderText>,
listState: LazyListState,
currentChapter: Chapter?,
nestedScrollConnection: NestedScrollConnection,
@ -47,7 +47,6 @@ fun ReaderScaffold(
checkpoint: Checkpoint,
showMenu: Boolean,
lockMenu: Boolean,
chapters: Map<Int, Chapter>,
contentPadding: PaddingValues,
verticalPadding: Dp,
horizontalGesture: ReaderHorizontalGesture,
@ -136,7 +135,6 @@ fun ReaderScaffold(
) {
ReaderLayout(
text = text,
chapters = chapters,
listState = listState,
contentPadding = contentPadding,
verticalPadding = verticalPadding,

View file

@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.ReaderText.Chapter
import ua.acclorite.book_story.presentation.core.components.common.IconButton
import ua.acclorite.book_story.presentation.core.util.LocalActivity
import ua.acclorite.book_story.presentation.core.util.noRippleClickable

View file

@ -3,6 +3,7 @@ package ua.acclorite.book_story.ui.reader
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.reader.ReaderText.Chapter
@Immutable
sealed class ReaderEvent {
@ -25,7 +26,7 @@ sealed class ReaderEvent {
) : ReaderEvent()
data class OnScrollToChapter(
val chapterStartIndex: Int
val chapter: Chapter
) : ReaderEvent()
data class OnScroll(

View file

@ -3,6 +3,7 @@ package ua.acclorite.book_story.ui.reader
import android.app.SearchManager
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.snapshotFlow
@ -28,8 +29,8 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.Checkpoint
import ua.acclorite.book_story.domain.reader.ReaderText.Chapter
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.domain.use_case.book.GetBookById
import ua.acclorite.book_story.domain.use_case.book.GetText
@ -44,6 +45,8 @@ import ua.acclorite.book_story.ui.library.LibraryScreen
import javax.inject.Inject
import kotlin.math.roundToInt
private const val READER = "READER, MODEL"
@HiltViewModel
class ReaderModel @Inject constructor(
private val getBookById: GetBookById,
@ -67,20 +70,19 @@ class ReaderModel @Inject constructor(
when (event) {
is ReaderEvent.OnLoadText -> {
launch(Dispatchers.IO) {
val chaptersAndText = getText.execute(_state.value.book.id)
val text = getText.execute(_state.value.book.id)
yield()
if (chaptersAndText.text.isEmpty()) {
if (text.isEmpty()) {
_state.update {
it.copy(
isLoading = false,
errorMessage = UIText.StringResource(R.string.error_no_text) //todo rename
errorMessage = UIText.StringResource(R.string.error_no_text)
)
}
return@launch
}
yield()
val lastOpened = getLatestHistory.execute(_state.value.book.id)?.time
yield()
@ -89,8 +91,7 @@ class ReaderModel @Inject constructor(
book = it.book.copy(
lastOpened = lastOpened
),
chapters = chaptersAndText.chapters,
text = chaptersAndText.text
text = text
)
}
@ -177,17 +178,23 @@ class ReaderModel @Inject constructor(
is ReaderEvent.OnScrollToChapter -> {
launch {
_state.value.listState.requestScrollToItem(
event.chapterStartIndex
)
updateChapter(index = event.chapterStartIndex)
onEvent(
ReaderEvent.OnChangeProgress(
progress = calculateProgress(event.chapterStartIndex),
firstVisibleItemIndex = event.chapterStartIndex,
firstVisibleItemOffset = 0
_state.value.apply {
val chapterIndex = text.indexOf(event.chapter).takeIf { it != -1 }
if (chapterIndex == null) {
return@launch
}
listState.requestScrollToItem(chapterIndex)
updateChapter(index = chapterIndex)
onEvent(
ReaderEvent.OnChangeProgress(
progress = calculateProgress(chapterIndex),
firstVisibleItemIndex = chapterIndex,
firstVisibleItemOffset = 0
)
)
)
}
}
}
@ -195,7 +202,6 @@ class ReaderModel @Inject constructor(
scrollJob?.cancel()
scrollJob = launch {
delay(300)
yield()
val scrollTo = (_state.value.text.size * event.progress).roundToInt()
@ -211,6 +217,7 @@ class ReaderModel @Inject constructor(
checkpoint.offset
)
updateChapter(checkpoint.index)
onEvent(
ReaderEvent.OnChangeProgress(
progress = calculateProgress(checkpoint.index),
@ -234,9 +241,9 @@ class ReaderModel @Inject constructor(
_state.value.listState.apply {
if (
_state.value.isLoading
|| layoutInfo.totalItemsCount < 1
|| _state.value.text.isEmpty()
_state.value.isLoading ||
layoutInfo.totalItemsCount < 1 ||
_state.value.text.isEmpty()
) return@apply
_state.update {
@ -284,6 +291,8 @@ class ReaderModel @Inject constructor(
"translate: ${event.textToTranslate.trim()}"
)
yield()
translatorIntent.launchActivity(
activity = event.activity,
createChooser = !event.translateWholeParagraph,
@ -320,6 +329,8 @@ class ReaderModel @Inject constructor(
event.textToShare.trim()
)
yield()
shareIntent.launchActivity(
activity = event.activity,
createChooser = true,
@ -345,6 +356,8 @@ class ReaderModel @Inject constructor(
event.textToSearch
)
yield()
browserIntent.launchActivity(
activity = event.activity,
success = {
@ -376,6 +389,8 @@ class ReaderModel @Inject constructor(
val text = event.textToDefine.trim().replace(" ", "+")
browserIntent.data = Uri.parse("https://www.onelook.com/?w=$text")
yield()
dictionaryIntent.launchActivity(
activity = event.activity,
createChooser = true,
@ -485,6 +500,10 @@ class ReaderModel @Inject constructor(
if (progress == _state.value.book.progress) return@collectLatest
val (currentChapter, currentChapterProgress) = calculateCurrentChapter(index)
Log.i(
READER,
"Changed progress|currentChapter: $progress; ${currentChapter?.title}"
)
_state.update {
it.copy(
book = it.book.copy(
@ -509,6 +528,11 @@ class ReaderModel @Inject constructor(
viewModelScope.launch {
val (currentChapter, currentChapterProgress) = calculateCurrentChapter(index)
_state.update {
Log.i(
READER,
"Changed currentChapter|currentChapterProgress:" +
" ${currentChapter?.title}($currentChapterProgress)"
)
it.copy(
currentChapter = currentChapter,
currentChapterProgress = currentChapterProgress
@ -518,20 +542,33 @@ class ReaderModel @Inject constructor(
}
private fun calculateCurrentChapter(index: Int): Pair<Chapter?, Float> {
val currentChapter = _state.value.chapters.find { chapter ->
index in chapter.startIndex..chapter.endIndex
}
val currentChapterProgress = currentChapter.run {
if (this == null) return@run 0f
val currentChapter = findCurrentChapter(index)
val currentChapterProgress = currentChapter?.let { chapter ->
_state.value.text.run {
val startIndex = (indexOf(chapter) + 1).coerceAtMost(count())
val endIndex = (indexOfFirst {
it is Chapter && indexOf(it) > startIndex
}.takeIf { it != -1 }?.minus(1)) ?: lastIndex
val currentIndex = index - startIndex
val endIndex = endIndex - startIndex
(currentIndex / endIndex.toFloat())
val currentIndexInChapter = index - startIndex
val chapterLength = endIndex - startIndex
(currentIndexInChapter / chapterLength.toFloat())
}
}.coerceAndPreventNaN()
return currentChapter to currentChapterProgress
}
private fun findCurrentChapter(index: Int): Chapter? {
for (textIndex in index downTo 0) {
val readerText = _state.value.text[textIndex]
if (readerText is Chapter) {
return readerText
}
}
return null
}
private fun calculateProgress(firstVisibleItemIndex: Int? = null): Float {
return _state.value.run {
if (

View file

@ -34,7 +34,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.parcelize.Parcelize
import ua.acclorite.book_story.domain.navigator.Screen
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.ReaderTextAlignment
import ua.acclorite.book_story.presentation.core.constants.Constants
import ua.acclorite.book_story.presentation.core.constants.provideFonts
@ -224,14 +223,6 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
(mainState.value.bottomBarPadding * 4f).dp
}
val chapters = remember(state.value.chapters) {
val chapters = mutableMapOf<Int, Chapter>()
state.value.chapters.forEach {
chapters[it.startIndex] = it
}
chapters
}
LaunchedEffect(Unit) {
screenModel.init(
bookId = bookId,
@ -313,7 +304,6 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
checkpoint = state.value.checkpoint,
showMenu = state.value.showMenu,
lockMenu = state.value.lockMenu,
chapters = chapters,
contentPadding = contentPadding,
verticalPadding = verticalPadding,
horizontalGesture = mainState.value.horizontalGesture,

View file

@ -2,10 +2,10 @@ package ua.acclorite.book_story.ui.reader
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.reader.Chapter
import ua.acclorite.book_story.domain.reader.Checkpoint
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.domain.reader.ReaderText.Chapter
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.domain.util.BottomSheet
import ua.acclorite.book_story.domain.util.Drawer
@ -15,8 +15,7 @@ import ua.acclorite.book_story.presentation.core.constants.provideEmptyBook
@Immutable
data class ReaderState(
val book: Book = Constants.provideEmptyBook(),
val chapters: List<Chapter> = emptyList(),
val text: List<AnnotatedString> = emptyList(),
val text: List<ReaderText> = emptyList(),
val listState: LazyListState = LazyListState(),
val currentChapter: Chapter? = null,