🚀 Chapters in Reader

* Added chapters in Reader
* ModalDrawer to fast navigate or track current chapter
* Current chapter progress in top bar
* Optimized Reader scroll performance
* Improved EPUB parser
* Fixed incorrect order for chapters(due to no sorting before)
* Remove ZIP parser (replaced with EPUB)

Resolves: #66, #17
This commit is contained in:
Acclorite 2024-09-15 18:09:50 +03:00
parent 06fd97bd88
commit 94c49c20c8
43 changed files with 1191 additions and 615 deletions

View file

@ -166,4 +166,7 @@ dependencies {
// Scrollbar
implementation("com.github.nanihadesuka:LazyColumnScrollbar:2.2.0")
// Gson
implementation("com.google.code.gson:gson:2.11.0")
}

View file

@ -0,0 +1,203 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "37c3fbdf5d779680f100fbdfe99121f7",
"entities": [
{
"tableName": "BookEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `description` TEXT, `textPath` TEXT NOT NULL, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `category` TEXT NOT NULL, `chapters` TEXT NOT NULL DEFAULT '[]')",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "textPath",
"columnName": "textPath",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filePath",
"columnName": "filePath",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "scrollIndex",
"columnName": "scrollIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scrollOffset",
"columnName": "scrollOffset",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "progress",
"columnName": "progress",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "image",
"columnName": "image",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "chapters",
"columnName": "chapters",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'[]'"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HistoryEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookId",
"columnName": "bookId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ColorPresetEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "backgroundColor",
"columnName": "backgroundColor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fontColor",
"columnName": "fontColor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isSelected",
"columnName": "isSelected",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "FavoriteDirectoryEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`path` TEXT NOT NULL, PRIMARY KEY(`path`))",
"fields": [
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"path"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '37c3fbdf5d779680f100fbdfe99121f7')"
]
}
}

View file

@ -0,0 +1,25 @@
package ua.acclorite.book_story.data.local.converter
import androidx.room.TypeConverter
import com.google.common.reflect.TypeToken
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import ua.acclorite.book_story.domain.model.Chapter
class ChapterConverter {
@TypeConverter
fun fromChapterList(chapters: List<Chapter>): String {
return Gson().toJson(chapters)
}
@TypeConverter
fun toChapterList(data: String): List<Chapter> {
val listType = object : TypeToken<List<Chapter>>() {}.type
return try {
Gson().fromJson<List<Chapter>?>(data, listType).sortedBy { it.index }
} catch (j: JsonSyntaxException) {
j.printStackTrace()
emptyList()
}
}
}

View file

@ -1,16 +1,23 @@
package ua.acclorite.book_story.data.local.dto
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import ua.acclorite.book_story.data.local.converter.ChapterConverter
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.model.Chapter
@Entity
@TypeConverters(ChapterConverter::class)
data class BookEntity(
@PrimaryKey(true) val id: Int = 0,
val title: String,
val author: String?,
val description: String?,
val textPath: String,
@ColumnInfo(defaultValue = "[]")
val chapters: List<Chapter>,
val filePath: String,
val scrollIndex: Int,
val scrollOffset: Int,

View file

@ -20,13 +20,14 @@ import ua.acclorite.book_story.data.local.dto.HistoryEntity
ColorPresetEntity::class,
FavoriteDirectoryEntity::class,
],
version = 6,
version = 7,
autoMigrations = [
AutoMigration(1, 2),
AutoMigration(2, 3),
AutoMigration(3, 4, spec = DatabaseHelper.MIGRATION_3_4::class),
AutoMigration(4, 5),
AutoMigration(5, 6),
AutoMigration(6, 7),
],
exportSchema = true
)
@ -72,7 +73,7 @@ object DatabaseHelper {
}
}
val MIGRATION_5_6 = object : Migration(4, 5) {
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `FavoriteDirectoryEntity` (" +

View file

@ -20,7 +20,8 @@ class BookMapperImpl @Inject constructor() : BookMapper {
textPath = book.textPath,
description = book.description,
image = if (book.coverImage != null) book.coverImage.toString() else null,
category = book.category
category = book.category,
chapters = book.chapters
)
}
@ -40,6 +41,7 @@ class BookMapperImpl @Inject constructor() : BookMapper {
lastOpened = null,
category = bookEntity.category,
coverImage = if (bookEntity.image != null) Uri.parse(bookEntity.image) else null,
chapters = bookEntity.chapters
)
}
}

View file

@ -1,28 +1,30 @@
package ua.acclorite.book_story.data.parser
import android.util.Log
import ua.acclorite.book_story.data.parser.epub.EpubFileParser
import ua.acclorite.book_story.data.parser.fb2.Fb2FileParser
import ua.acclorite.book_story.data.parser.htm.HtmFileParser
import ua.acclorite.book_story.data.parser.html.HtmlFileParser
import ua.acclorite.book_story.data.parser.pdf.PdfFileParser
import ua.acclorite.book_story.data.parser.txt.TxtFileParser
import ua.acclorite.book_story.data.parser.zip.ZipFileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.util.CoverImage
import java.io.File
import javax.inject.Inject
private const val FILE_PARSER = "File Parser"
class FileParserImpl @Inject constructor(
private val txtFileParser: TxtFileParser,
private val pdfFileParser: PdfFileParser,
private val epubFileParser: EpubFileParser,
private val fb2FileParser: Fb2FileParser,
private val zipFileParser: ZipFileParser,
private val htmlFileParser: HtmlFileParser,
private val htmFileParser: HtmFileParser,
) : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.exists()) {
Log.e(FILE_PARSER, "File does not exist.")
return null
}
@ -45,7 +47,7 @@ class FileParserImpl @Inject constructor(
}
".zip" -> {
zipFileParser.parse(file)
epubFileParser.parse(file)
}
".html" -> {
@ -56,7 +58,10 @@ class FileParserImpl @Inject constructor(
htmFileParser.parse(file)
}
else -> null
else -> {
Log.e(FILE_PARSER, "Wrong file format, could not find supported extension.")
null
}
}
}
}

View file

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

View file

@ -1,5 +1,6 @@
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
@ -7,23 +8,25 @@ import ua.acclorite.book_story.data.parser.htm.HtmTextParser
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.data.parser.zip.ZipTextParser
import ua.acclorite.book_story.domain.model.ChapterWithText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import javax.inject.Inject
private const val TEXT_PARSER = "Text Parser"
class TextParserImpl @Inject constructor(
private val txtTextParser: TxtTextParser,
private val pdfTextParser: PdfTextParser,
private val epubTextParser: EpubTextParser,
private val fb2TextParser: Fb2TextParser,
private val zipTextParser: ZipTextParser,
private val htmlTextParser: HtmlTextParser,
private val htmTextParser: HtmTextParser,
) : TextParser {
override suspend fun parse(file: File): Resource<List<String>> {
override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
if (!file.exists()) {
Log.e(TEXT_PARSER, "File does not exist.")
return Resource.Error(
UIText.StringResource(R.string.error_something_went_wrong_with_file)
)
@ -48,7 +51,7 @@ class TextParserImpl @Inject constructor(
}
".zip" -> {
zipTextParser.parse(file)
epubTextParser.parse(file)
}
".html" -> {
@ -59,7 +62,10 @@ class TextParserImpl @Inject constructor(
htmTextParser.parse(file)
}
else -> Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
else -> {
Log.e(TEXT_PARSER, "Wrong file format, could not find supported extension.")
Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
}
}
}

View file

@ -18,11 +18,7 @@ import javax.inject.Inject
class EpubFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.name.endsWith(".epub", true) || !file.exists()) {
return null
}
try {
return try {
var book: Pair<Book, CoverImage?>? = null
withContext(Dispatchers.IO) {
@ -90,11 +86,10 @@ class EpubFileParser @Inject constructor() : FileParser {
) to extractCoverImageBitmap(file, coverImage)
}
}
return book
book
} catch (e: Exception) {
e.printStackTrace()
return null
}
null
}
}
@ -114,3 +109,4 @@ private fun extractCoverImageBitmap(file: File, coverImagePath: String?): Bitmap
return null
}
}

View file

@ -1,64 +1,63 @@
package ua.acclorite.book_story.data.parser.epub
import android.net.Uri
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.model.Chapter
import ua.acclorite.book_story.domain.model.ChapterWithText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import javax.inject.Inject
private const val EPUB_TAG = "EPUB Parser"
class EpubTextParser @Inject constructor() : TextParser {
override suspend fun parse(file: File): Resource<List<String>> {
if (!file.name.endsWith(".epub", true) || !file.exists()) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
Log.i(EPUB_TAG, "Started EPUB parsing: ${file.name}.")
try {
val lines = mutableListOf<String>()
return try {
val chapters = mutableListOf<ChapterWithText>()
withContext(Dispatchers.IO) {
ZipFile(file).use { zip ->
zip.entries().asSequence().forEach { entry ->
if (
entry.name.endsWith(".xhtml")
|| entry.name.endsWith(".html")
|| entry.name.endsWith(".xml")
|| entry.name.endsWith(".htm")
) {
val content = zip.getInputStream(entry).bufferedReader()
.use {
it.readText()
zip.entries().asSequence().find { entry ->
entry.name.endsWith("toc.ncx", ignoreCase = true)
}.apply {
if (this == null) {
Log.w(EPUB_TAG, "toc.ncx was not found.")
parseWithoutToc(zip)?.let { chapters.addAll(it) }
return@withContext
}
val document = Jsoup.parse(content)
document.select("p").append("\n")
document
.wholeText()
.lines()
.forEach { line ->
if (line.isNotBlank()) {
lines.add(line.trim())
}
parseWithToc(this, zip).apply {
if (this == null) {
parseWithoutToc(zip)?.let { chapters.addAll(it) }
return@withContext
}
chapters.addAll(this)
}
}
}
}
if (lines.isEmpty()) {
if (chapters.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
return Resource.Success(lines)
Log.i(EPUB_TAG, "Successfully finished EPUB parsing.")
Resource.Success(chapters)
} catch (e: Exception) {
e.printStackTrace()
return Resource.Error(
Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""
@ -66,4 +65,166 @@ class EpubTextParser @Inject constructor() : TextParser {
)
}
}
/**
* Parses text if no toc.ncx found, which is Table of Content.
*
* @return Null if could not parse.
*/
private fun parseWithoutToc(zip: ZipFile): List<ChapterWithText>? {
val chapters = mutableListOf<ChapterWithText>()
var chapterTextIndex = -1
var chapterIndex = 1
zip.entries().asSequence().sortedBy { it.name }.forEach { entry ->
if (
!entry.name.endsWith(".xhtml")
&& !entry.name.endsWith(".html")
&& !entry.name.endsWith(".xml")
&& !entry.name.endsWith(".htm")
|| entry.name.endsWith("container.xml")
) return@forEach
val chapter = zip.parseDocument(entry = entry, fragment = null)
if (chapter.isEmpty()) {
Log.w(EPUB_TAG, "Chapter ${entry.name} is empty.")
return@forEach
}
chapters.add(
ChapterWithText(
chapter = Chapter(
index = chapters.size,
title = "Chapter $chapterIndex", // Generic name, but at least something
startIndex = chapterTextIndex + 1,
endIndex = chapterTextIndex + chapter.size
),
text = chapter
)
)
chapterTextIndex += chapter.size
chapterIndex++
}
if (chapters.isEmpty()) {
Log.e(EPUB_TAG, "Could not parse file without toc.ncx")
return null
}
return chapters
}
/**
* Parses text with toc.ncx. Extracts all chapters.
*
* @return Null if could not parse toc.ncx.
*/
private fun parseWithToc(tocEntry: ZipEntry, zip: ZipFile): List<ChapterWithText>? {
Log.i(EPUB_TAG, "TOC Entry: ${tocEntry.name}")
val chapters = mutableListOf<ChapterWithText>()
var emptyChapters = 0
var chapterTextIndex = -1
var chapterIndex = 1
val tocContent = zip.getInputStream(tocEntry)
.bufferedReader()
.use { it.readText() }
val tocDocument = Jsoup.parse(tocContent)
tocDocument.select("navPoint").forEach { navPoint ->
val chapterTitle = navPoint.selectFirst("navLabel > text")?.text()?.trim()
?: "Chapter $chapterIndex"
val chapterSrc = navPoint.selectFirst("content")?.attr("src")?.trim()
.run {
if (this == null) {
Log.e(EPUB_TAG, "No source of the chapter found: $chapterTitle")
return null
}
val uri = Uri.parse(this) ?: return@run this to null
(uri.path ?: this) to uri.fragment
}
zip.entries().asSequence().find { entry ->
entry.name.endsWith(chapterSrc.first)
}.apply {
if (this == null) {
Log.e(EPUB_TAG, "No chapter entry found: $chapterTitle")
return null
}
val chapter = zip.parseDocument(
entry = this,
fragment = chapterSrc.second
).dropWhile {
it == chapterTitle // Remove chapter title if present
}
if (chapter.isEmpty()) {
Log.w(EPUB_TAG, "Chapter $chapterTitle is empty.")
emptyChapters += 1
return@forEach
}
chapters.add(
ChapterWithText(
chapter = Chapter(
index = chapters.size,
title = chapterTitle,
startIndex = chapterTextIndex + 1,
endIndex = chapterTextIndex + chapter.size
),
text = chapter
)
)
chapterTextIndex += chapter.size
chapterIndex++
}
}
if (chapters.isEmpty()) {
Log.e(EPUB_TAG, "Could not parse text with toc.ncx")
return null
}
if (emptyChapters >= ((emptyChapters + chapters.size) * 0.25f)) {
Log.e(EPUB_TAG, "More than 25% of the chapters are empty.")
return null
}
return chapters
}
/**
* Parses [entry] to get it's text.
*
* @return Parsed text line by line. Can have line break issues due to bad [entry] formatting.
*/
private fun ZipFile.parseDocument(entry: ZipEntry, fragment: String?): List<String> {
val lines = mutableListOf<String>()
val content = getInputStream(entry)
.bufferedReader()
.use {
it.readText()
}
val document = Jsoup.parse(content)
document.select("p").append("\n")
document.select("head > title").remove()
document
.run {
fragment?.let { return@run getElementById(it) ?: this }
this
}
.wholeText()
.lines()
.forEach { line ->
if (line.isNotBlank()) {
lines.add(line.trim())
}
}
return lines
}
}

View file

@ -17,11 +17,7 @@ import javax.xml.parsers.DocumentBuilderFactory
class Fb2FileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.name.endsWith(".fb2", true) || !file.exists()) {
return null
}
try {
return try {
val factory = DocumentBuilderFactory.newInstance()
val builder = factory.newDocumentBuilder()
val document = withContext(Dispatchers.IO) {
@ -54,7 +50,7 @@ class Fb2FileParser @Inject constructor() : FileParser {
val descriptionFromFile = extractElementContent(document, "annotation")
return Book(
Book(
title = title,
author = author,
description = descriptionFromFile,
@ -69,7 +65,7 @@ class Fb2FileParser @Inject constructor() : FileParser {
) to null
} catch (e: Exception) {
e.printStackTrace()
return null
null
}
}

View file

@ -1,23 +1,26 @@
package ua.acclorite.book_story.data.parser.fb2
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.w3c.dom.Element
import org.w3c.dom.NodeList
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.model.ChapterWithText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import ua.acclorite.book_story.presentation.core.constants.Constants
import java.io.File
import javax.inject.Inject
import javax.xml.parsers.DocumentBuilderFactory
private const val FB2_TAG = "FB2 Parser"
class Fb2TextParser @Inject constructor() : TextParser {
override suspend fun parse(file: File): Resource<List<String>> {
if (!file.name.endsWith(".fb2", true) || !file.exists()) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
Log.i(FB2_TAG, "Started FB2 parsing: ${file.name}.")
return try {
val factory = DocumentBuilderFactory.newInstance()
@ -38,6 +41,7 @@ class Fb2TextParser @Inject constructor() : TextParser {
val unformattedLines = mutableListOf<String>()
val bodyNode = bodyNodes.item(0) as Element
val paragraphNodes = bodyNode.getElementsByTagName("p")
for (element in paragraphNodes.asList()) {
if (element.textContent.isBlank()) {
continue
@ -100,7 +104,15 @@ class Fb2TextParser @Inject constructor() : TextParser {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
Resource.Success(formattedLines)
Log.i(FB2_TAG, "Successfully finished FB2 parsing.")
Resource.Success(
listOf(
ChapterWithText(
chapter = Constants.EMPTY_CHAPTER,
text = formattedLines
)
)
)
} catch (e: Exception) {
e.printStackTrace()
Resource.Error(

View file

@ -13,11 +13,7 @@ import javax.inject.Inject
class HtmFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.name.endsWith(".htm", true) || !file.exists()) {
return null
}
try {
return try {
val document = Jsoup.parse(file)
val title = document.select("head > title").text().trim().run {
@ -26,7 +22,7 @@ class HtmFileParser @Inject constructor() : FileParser {
}
}
return Book(
Book(
title = title,
author = UIText.StringResource(R.string.unknown_author),
description = null,
@ -41,7 +37,7 @@ class HtmFileParser @Inject constructor() : FileParser {
) to null
} catch (e: Exception) {
e.printStackTrace()
return null
null
}
}
}

View file

@ -1,26 +1,30 @@
package ua.acclorite.book_story.data.parser.htm
import android.util.Log
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.model.Chapter
import ua.acclorite.book_story.domain.model.ChapterWithText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import javax.inject.Inject
private const val HTM_TAG = "HTM Parser"
class HtmTextParser @Inject constructor() : TextParser {
override suspend fun parse(file: File): Resource<List<String>> {
if (!file.name.endsWith(".htm", true) || !file.exists()) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
Log.i(HTM_TAG, "Started HTM parsing: ${file.name}.")
try {
return try {
val lines = mutableListOf<String>()
val document = Jsoup.parse(file)
document.select("p").append("\n")
document.select("head > title").remove()
document
.wholeText()
.lines()
@ -34,10 +38,18 @@ class HtmTextParser @Inject constructor() : TextParser {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
return Resource.Success(lines)
Log.i(HTM_TAG, "Successfully finished HTM parsing.")
Resource.Success(
listOf(
ChapterWithText(
chapter = Chapter(title = "", startIndex = 0, endIndex = 0),
text = lines
)
)
)
} catch (e: Exception) {
e.printStackTrace()
return Resource.Error(
Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""

View file

@ -13,11 +13,7 @@ import javax.inject.Inject
class HtmlFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.name.endsWith(".html", true) || !file.exists()) {
return null
}
try {
return try {
val document = Jsoup.parse(file)
val title = document.select("head > title").text().trim().run {
@ -26,7 +22,7 @@ class HtmlFileParser @Inject constructor() : FileParser {
}
}
return Book(
Book(
title = title,
author = UIText.StringResource(R.string.unknown_author),
description = null,
@ -41,7 +37,7 @@ class HtmlFileParser @Inject constructor() : FileParser {
) to null
} catch (e: Exception) {
e.printStackTrace()
return null
null
}
}
}

View file

@ -1,26 +1,30 @@
package ua.acclorite.book_story.data.parser.html
import android.util.Log
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.model.Chapter
import ua.acclorite.book_story.domain.model.ChapterWithText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import javax.inject.Inject
private const val HTML_TAG = "HTML Parser"
class HtmlTextParser @Inject constructor() : TextParser {
override suspend fun parse(file: File): Resource<List<String>> {
if (!file.name.endsWith(".html", true) || !file.exists()) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
Log.i(HTML_TAG, "Started HTML parsing: ${file.name}.")
try {
return try {
val lines = mutableListOf<String>()
val document = Jsoup.parse(file)
document.select("p").append("\n")
document.select("head > title").remove()
document
.wholeText()
.lines()
@ -34,10 +38,18 @@ class HtmlTextParser @Inject constructor() : TextParser {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
return Resource.Success(lines)
Log.i(HTML_TAG, "Successfully finished HTML parsing.")
Resource.Success(
listOf(
ChapterWithText(
chapter = Chapter(title = "", startIndex = 0, endIndex = 0),
text = lines
)
)
)
} catch (e: Exception) {
e.printStackTrace()
return Resource.Error(
Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""

View file

@ -15,11 +15,7 @@ import javax.inject.Inject
class PdfFileParser @Inject constructor(private val application: Application) : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.name.endsWith(".pdf", true) || !file.exists()) {
return null
}
try {
return try {
PDFBoxResourceLoader.init(application)
val document = PDDocument.load(file)
@ -33,7 +29,7 @@ class PdfFileParser @Inject constructor(private val application: Application) :
document.close()
return Book(
Book(
title = title,
author = author,
description = description,
@ -48,7 +44,7 @@ class PdfFileParser @Inject constructor(private val application: Application) :
) to null
} catch (e: Exception) {
e.printStackTrace()
return null
null
}
}
}

View file

@ -1,26 +1,25 @@
package ua.acclorite.book_story.data.parser.pdf
import android.app.Application
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import android.util.Log
import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.text.PDFTextStripper
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.model.ChapterWithText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import ua.acclorite.book_story.presentation.core.constants.Constants
import java.io.File
import javax.inject.Inject
class PdfTextParser @Inject constructor(private val application: Application) : TextParser {
private const val PDF_TAG = "PDF Parser"
override suspend fun parse(file: File): Resource<List<String>> {
if (!file.name.endsWith(".pdf", true) || !file.exists()) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
class PdfTextParser @Inject constructor() : TextParser {
try {
PDFBoxResourceLoader.init(application)
override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
Log.i(PDF_TAG, "Started PDF parsing: ${file.name}.")
return try {
val document = PDDocument.load(file)
val strings = mutableListOf<String>()
@ -95,10 +94,18 @@ class PdfTextParser @Inject constructor(private val application: Application) :
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
return Resource.Success(strings)
Log.i(PDF_TAG, "Successfully finished PDF parsing.")
Resource.Success(
listOf(
ChapterWithText(
chapter = Constants.EMPTY_CHAPTER,
text = strings
)
)
)
} catch (e: Exception) {
e.printStackTrace()
return Resource.Error(
Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""

View file

@ -12,15 +12,11 @@ import javax.inject.Inject
class TxtFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.name.endsWith(".txt", true) || !file.exists()) {
return null
}
try {
return try {
val title = file.nameWithoutExtension.trim()
val author = UIText.StringResource(R.string.unknown_author)
return Book(
Book(
title = title,
author = author,
description = null,
@ -35,7 +31,7 @@ class TxtFileParser @Inject constructor() : FileParser {
) to null
} catch (e: Exception) {
e.printStackTrace()
return null
null
}
}
}

View file

@ -1,41 +1,52 @@
package ua.acclorite.book_story.data.parser.txt
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.model.ChapterWithText
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import ua.acclorite.book_story.presentation.core.constants.Constants
import java.io.BufferedReader
import java.io.File
import java.io.FileReader
import javax.inject.Inject
private const val TXT_TAG = "TXT Parser"
class TxtTextParser @Inject constructor() : TextParser {
override suspend fun parse(file: File): Resource<List<String>> {
if (!file.name.endsWith(".txt", true) || !file.exists()) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
Log.i(TXT_TAG, "Started TXT parsing: ${file.name}.")
return try {
val formattedLines = mutableListOf<String>()
val lines = mutableListOf<String>()
withContext(Dispatchers.IO) {
BufferedReader(FileReader(file)).forEachLine { line ->
if (line.isNotBlank()) {
formattedLines.add(
lines.add(
line.trim()
)
}
}
}
if (formattedLines.isEmpty()) {
if (lines.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
Resource.Success(formattedLines)
Log.i(TXT_TAG, "Successfully finished TXT parsing.")
Resource.Success(
listOf(
ChapterWithText(
chapter = Constants.EMPTY_CHAPTER,
text = lines
)
)
)
} catch (e: Exception) {
e.printStackTrace()
Resource.Error(

View file

@ -1,116 +0,0 @@
package ua.acclorite.book_story.data.parser.zip
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import java.util.zip.ZipFile
import javax.inject.Inject
class ZipFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.name.endsWith(".zip", true) || !file.exists()) {
return null
}
try {
var book: Pair<Book, CoverImage?>? = null
withContext(Dispatchers.IO) {
ZipFile(file).use { zip ->
val opfEntry = zip.entries().asSequence().find { entry ->
entry.name.endsWith(".opf")
} ?: return@withContext
val opfContent = zip
.getInputStream(opfEntry)
.bufferedReader()
.use { it.readText() }
val document = Jsoup.parse(opfContent)
val title = document.select("metadata > dc|title").text().trim().run {
ifBlank {
file.nameWithoutExtension.trim()
}
}
val author = document.select("metadata > dc|creator").text().trim().run {
if (isBlank()) {
UIText.StringResource(R.string.unknown_author)
} else {
UIText.StringValue(this)
}
}
val description = Jsoup.parse(
document.select("metadata > dc|description").text()
).text().run {
ifBlank {
null
}
}
val coverImage = document
.select("metadata > meta[name=cover]")
.attr("content")
.run {
if (isNotBlank()) {
document
.select("manifest > item[id=$this]")
.attr("href")
.apply { if (isNotBlank()) return@run this }
}
document
.select("manifest > item[media-type*=image]")
.firstOrNull()?.attr("href")
}
book = Book(
title = title,
author = author,
description = description,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
) to extractCoverImageBitmap(file, coverImage)
}
}
return book
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
}
private fun extractCoverImageBitmap(file: File, coverImagePath: String?): Bitmap? {
if (coverImagePath.isNullOrBlank()) {
return null
}
ZipFile(file).use { zip ->
zip.entries().asSequence().forEach { entry ->
if (entry.name.endsWith(coverImagePath)) {
val imageBytes = zip.getInputStream(entry).readBytes()
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
}
}
}
return null
}

View file

@ -1,69 +0,0 @@
package ua.acclorite.book_story.data.parser.zip
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import java.util.zip.ZipFile
import javax.inject.Inject
class ZipTextParser @Inject constructor() : TextParser {
override suspend fun parse(file: File): Resource<List<String>> {
if (!file.name.endsWith(".zip", true) || !file.exists()) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
try {
val lines = mutableListOf<String>()
withContext(Dispatchers.IO) {
ZipFile(file).use { zip ->
zip.entries().asSequence().forEach { entry ->
if (
entry.name.endsWith(".xhtml")
|| entry.name.endsWith(".html")
|| entry.name.endsWith(".xml")
|| entry.name.endsWith(".htm")
) {
val content = zip.getInputStream(entry).bufferedReader()
.use {
it.readText()
}
val document = Jsoup.parse(content)
document.select("p").append("\n")
document
.wholeText()
.lines()
.forEach { line ->
if (line.isNotBlank()) {
lines.add(line.trim())
}
}
}
}
}
}
if (lines.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
return Resource.Success(lines)
} catch (e: Exception) {
e.printStackTrace()
return Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""
)
)
}
}
}

View file

@ -40,13 +40,18 @@ import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import ua.acclorite.book_story.presentation.core.constants.Constants
import ua.acclorite.book_story.presentation.data.MainState
import java.io.BufferedReader
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.FileReader
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private const val GET_BOOK_FROM_FILE = "GET BOOK FROM FILE, REPOSITORY"
private const val GET_TEXT = "GET TEXT, REPOSITORY"
@Suppress("DEPRECATION")
@Singleton
class BookRepositoryImpl @Inject constructor(
@ -96,21 +101,31 @@ class BookRepositoryImpl @Inject constructor(
}
}
/**
* Loads text from given path. Should be .txt.
* Used to get text from book and load Reader.
*/
override suspend fun getBookText(textPath: String): List<String> {
val textFile = File(textPath)
val lines = mutableListOf<String>()
if (textPath.isBlank() || !textFile.exists()) {
Log.w("BOOK TEXT", "Failed to load file")
if (textPath.isBlank() || !textFile.exists() || textFile.extension != "txt") {
Log.w(GET_TEXT, "Failed to load file: $textPath")
return emptyList()
}
val text = textParser.parse(textFile)
if (text.data.isNullOrEmpty()) {
return emptyList()
withContext(Dispatchers.IO) {
BufferedReader(FileReader(textFile)).forEachLine { line ->
if (line.isNotBlank()) {
lines.add(
line.trim()
)
}
}
}
return text.data
Log.i(GET_TEXT, "Successfully loaded text.")
return lines
}
override suspend fun insertBook(
@ -555,74 +570,40 @@ class BookRepositoryImpl @Inject constructor(
return rootDirectory.getAllFiles()
}
override suspend fun getBooksFromFiles(files: List<File>): List<NullableBook> {
val books = mutableListOf<NullableBook>()
for (file in files) {
val parsedBook = if (Constants.EXTENSIONS.any { file.name.endsWith(it, true) }) {
fileParser.parse(file)
} else {
books.add(
NullableBook.Null(
file.name,
UIText.StringResource(R.string.error_wrong_file_format)
)
)
continue
}
val parsedText = if (Constants.EXTENSIONS.any { file.name.endsWith(it, true) }) {
textParser.parse(file)
} else {
books.add(
NullableBook.Null(
file.name,
UIText.StringResource(R.string.error_wrong_file_format)
)
)
continue
}
/**
* Gets book from given file. If error happened, returns [NullableBook.Null].
*/
override suspend fun getBookFromFile(file: File): NullableBook {
val parsedBook = fileParser.parse(file)
if (parsedBook == null) {
books.add(
NullableBook.Null(
Log.w(GET_BOOK_FROM_FILE, "Parsed book(${file.name}) is null.")
return NullableBook.Null(
file.name,
UIText.StringResource(R.string.error_something_went_wrong_with_file)
UIText.StringResource(R.string.error_wrong_file_format)
)
)
continue
}
val parsedText = textParser.parse(file)
if (parsedText is Resource.Error) {
books.add(
NullableBook.Null(
Log.w(GET_BOOK_FROM_FILE, "Parsed text(${file.name}) has error.")
return NullableBook.Null(
file.name,
parsedText.message
)
)
continue
}
if (parsedText.data == null) {
books.add(
NullableBook.Null(
file.name,
UIText.StringResource(R.string.error_file_empty)
)
)
continue
return NullableBook.NotNull(
book = parsedBook.first.copy(
chapters = parsedText.data!!.map { it.chapter }.run {
if (this.size == 1) return@run emptyList()
this
}
books.add(
NullableBook.NotNull(
book = parsedBook.first,
),
coverImage = parsedBook.second,
text = parsedText.data
text = parsedText.data.map {
it.text
}.flatten()
)
)
}
return books
}
override suspend fun insertHistory(history: List<History>) {

View file

@ -19,6 +19,7 @@ data class Book(
val scrollIndex: Int,
val scrollOffset: Int,
val progress: Float,
val chapters: List<Chapter> = emptyList(),
val lastOpened: Long?,
val category: Category,

View file

@ -0,0 +1,20 @@
package ua.acclorite.book_story.domain.model
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
@Immutable
data class ChapterWithText(
val chapter: Chapter,
val text: List<String>
)

View file

@ -68,7 +68,7 @@ interface BookRepository {
suspend fun getFilesFromDevice(query: String = ""): List<SelectableFile>
suspend fun getBooksFromFiles(files: List<File>): List<NullableBook>
suspend fun getBookFromFile(file: File): NullableBook
suspend fun insertHistory(history: List<History>)

View file

@ -10,6 +10,6 @@ class GetBookFromFile @Inject constructor(
) {
suspend fun execute(file: File): NullableBook {
return repository.getBooksFromFiles(listOf(file)).first()
return repository.getBookFromFile(file)
}
}

View file

@ -4,9 +4,9 @@ import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Inject
class UpdateBooks @Inject constructor(private val repository: BookRepository) {
class UpdateBook @Inject constructor(private val repository: BookRepository) {
suspend fun execute(books: List<Book>) {
repository.updateBooks(books)
suspend fun execute(book: Book) {
repository.updateBooks(listOf(book))
}
}

View file

@ -19,6 +19,7 @@ import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Badge
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.model.Chapter
import ua.acclorite.book_story.domain.model.ColorPreset
import ua.acclorite.book_story.domain.model.Credit
import ua.acclorite.book_story.domain.model.FontWithName
@ -64,6 +65,9 @@ object Constants {
// Default Color Preset (Used when creating new color presets)
val DEFAULT_COLOR_PRESET = provideDefaultColorPreset()
// Empty Chapter
val EMPTY_CHAPTER = provideEmptyChapter()
// Scrollbars (Primary unused, awaiting for fixes)
val PRIMARY_SCROLLBAR @Composable get() = providePrimaryScrollbar()
val SECONDARY_SCROLLBAR @Composable get() = provideSecondaryScrollbar()
@ -582,6 +586,13 @@ private fun provideDefaultColorPreset() = ColorPreset(
isSelected = false
)
private fun provideEmptyChapter() = Chapter(
index = 0,
title = "",
startIndex = 0,
endIndex = 0
)
@Composable
private fun providePrimaryScrollbar() = ScrollbarSettings(
thumbUnselectedColor = MaterialTheme.colorScheme.primary,

View file

@ -30,8 +30,8 @@ import ua.acclorite.book_story.domain.use_case.GetBookFromFile
import ua.acclorite.book_story.domain.use_case.GetText
import ua.acclorite.book_story.domain.use_case.InsertHistory
import ua.acclorite.book_story.domain.use_case.ResetCoverImage
import ua.acclorite.book_story.domain.use_case.UpdateBook
import ua.acclorite.book_story.domain.use_case.UpdateBookWithText
import ua.acclorite.book_story.domain.use_case.UpdateBooks
import ua.acclorite.book_story.domain.use_case.UpdateCoverImageOfBook
import ua.acclorite.book_story.domain.util.OnNavigate
import ua.acclorite.book_story.domain.util.UIText
@ -44,7 +44,7 @@ import javax.inject.Inject
@HiltViewModel
class BookInfoViewModel @Inject constructor(
private val updateBooks: UpdateBooks,
private val updateBook: UpdateBook,
private val updateBookWithText: UpdateBookWithText,
private val updateCoverImageOfBook: UpdateCoverImageOfBook,
private val insertHistory: InsertHistory,
@ -307,7 +307,7 @@ class BookInfoViewModel @Inject constructor(
else _state.value.book.description
)
updateBooks.execute(listOf(book))
updateBook.execute(book)
_state.update {
it.copy(
book = book
@ -385,7 +385,7 @@ class BookInfoViewModel @Inject constructor(
)
)
}
updateBooks.execute(listOf(_state.value.book))
updateBook.execute(_state.value.book)
event.refreshList(_state.value.book)
event.updatePage(

View file

@ -16,7 +16,7 @@ import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.use_case.DeleteBooks
import ua.acclorite.book_story.domain.use_case.GetBooks
import ua.acclorite.book_story.domain.use_case.InsertHistory
import ua.acclorite.book_story.domain.use_case.UpdateBooks
import ua.acclorite.book_story.domain.use_case.UpdateBook
import ua.acclorite.book_story.presentation.core.navigation.Screen
import ua.acclorite.book_story.presentation.core.util.BaseViewModel
import java.util.Date
@ -25,7 +25,7 @@ import javax.inject.Inject
@HiltViewModel
class LibraryViewModel @Inject constructor(
private val getBooks: GetBooks,
private val updateBooks: UpdateBooks,
private val updateBook: UpdateBook,
private val deleteBooks: DeleteBooks,
private val insertHistory: InsertHistory
) : BaseViewModel<LibraryState, LibraryEvent>() {
@ -240,7 +240,7 @@ class LibraryViewModel @Inject constructor(
val books = _state.value.books.filter {
it.second
}.map { it.first.copy(category = _state.value.selectedCategory) }
updateBooks.execute(books)
books.forEach { updateBook.execute(it) }
_state.update {
it.copy(

View file

@ -60,6 +60,7 @@ import androidx.core.view.WindowInsetsCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Chapter
import ua.acclorite.book_story.presentation.core.components.CustomAnimatedVisibility
import ua.acclorite.book_story.presentation.core.components.CustomSelectionContainer
import ua.acclorite.book_story.presentation.core.components.LocalHistoryViewModel
@ -76,6 +77,8 @@ import ua.acclorite.book_story.presentation.core.util.noRippleClickable
import ua.acclorite.book_story.presentation.core.util.showToast
import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.reader.components.ReaderChapter
import ua.acclorite.book_story.presentation.screens.reader.components.ReaderChaptersDrawer
import ua.acclorite.book_story.presentation.screens.reader.components.ReaderTextParagraph
import ua.acclorite.book_story.presentation.screens.reader.components.app_bar.ReaderBottomBar
import ua.acclorite.book_story.presentation.screens.reader.components.app_bar.ReaderTopBar
@ -158,6 +161,7 @@ fun ReaderScreenRoot(screen: Screen.Reader) {
onLibraryEvent(LibraryEvent.OnUpdateBook(it))
onHistoryEvent(HistoryEvent.OnUpdateBook(it))
}
viewModel.onUpdateCurrentChapter()
}
ReaderScreen(lazyListState = lazyListState)
@ -322,6 +326,15 @@ private fun ReaderScreen(lazyListState: LazyListState) {
)
}
val chapters = remember(state.value.book.chapters) {
val chapters = mutableMapOf<Int, Chapter>()
state.value.book.chapters.forEach {
chapters[it.startIndex] = it
}
chapters
}
// Bottom sheets & Dialogs
if (state.value.showSettingsBottomSheet) {
ReaderSettingsBottomSheet()
}
@ -460,7 +473,13 @@ private fun ReaderScreen(lazyListState: LazyListState) {
) {
customItemsIndexed(
state.value.text, key = { _, index -> index }
) { _, line ->
) { index, line ->
ReaderChapter(
chapter = chapters[index], // Shows only when matching startIndex of chapter.
fontColor = fontColor.value,
sidePadding = sidePadding
)
ReaderTextParagraph(
line = line,
context = context,
@ -531,6 +550,9 @@ private fun ReaderScreen(lazyListState: LazyListState) {
}
}
// Drawers
ReaderChaptersDrawer()
BackHandler {
onEvent(
ReaderEvent.OnGoBack(

View file

@ -0,0 +1,38 @@
package ua.acclorite.book_story.presentation.screens.reader.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.model.Chapter
/**
* Reader Chapter.
* Displays chapter title. Null if no chapter should be shown.
*
* @param chapter [Chapter].
* @param fontColor Font color to use for title and divider.
* @param sidePadding Side padding to apply to title.
*/
@Composable
fun ReaderChapter(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))
}
}

View file

@ -0,0 +1,81 @@
package ua.acclorite.book_story.presentation.screens.reader.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.presentation.core.components.LocalHistoryViewModel
import ua.acclorite.book_story.presentation.core.components.LocalLibraryViewModel
import ua.acclorite.book_story.presentation.core.components.LocalReaderViewModel
import ua.acclorite.book_story.presentation.core.components.customItems
import ua.acclorite.book_story.presentation.core.components.custom_drawer.CustomModalDrawer
import ua.acclorite.book_story.presentation.core.components.custom_drawer.CustomModalDrawerSelectableItem
import ua.acclorite.book_story.presentation.core.components.custom_drawer.CustomModalDrawerTitleItem
import ua.acclorite.book_story.presentation.core.util.calculateProgress
import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent
/**
* Reader Chapters Drawer.
* Shows the list of all chapter, current chapter and lets user to go to specific chapter.
*/
@Composable
fun ReaderChaptersDrawer() {
val state = LocalReaderViewModel.current.state
val onEvent = LocalReaderViewModel.current.onEvent
val onLibraryEvent = LocalLibraryViewModel.current.onEvent
val onHistoryEvent = LocalHistoryViewModel.current.onEvent
CustomModalDrawer(
show = state.value.showChaptersDrawer,
startIndex = state.value.currentChapter?.index ?: 0,
onDismissRequest = { onEvent(ReaderEvent.OnShowHideChaptersDrawer(false)) },
header = {
CustomModalDrawerTitleItem(
title = stringResource(id = R.string.chapters)
)
}
) {
customItems(state.value.book.chapters, key = { it.index }) { chapter ->
val selected = rememberSaveable(state.value.currentChapter) {
chapter.index == state.value.currentChapter?.index
}
CustomModalDrawerSelectableItem(
selected = selected,
onClick = {
onEvent(
ReaderEvent.OnScrollToChapter(
chapterStartIndex = chapter.startIndex,
refreshList = { book ->
onLibraryEvent(LibraryEvent.OnUpdateBook(book))
onHistoryEvent(HistoryEvent.OnUpdateBook(book))
}
)
)
onEvent(ReaderEvent.OnShowHideChaptersDrawer(false))
}
) {
Text(
text = chapter.title,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (selected) {
Spacer(modifier = Modifier.width(18.dp))
Text(
text = "${state.value.currentChapterProgress.calculateProgress(1)}%"
)
}
}
}
}
}

View file

@ -102,10 +102,9 @@ fun ReaderBottomBar() {
modifier = Modifier.padding(top = 3.dp, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
CustomAnimatedVisibility(
HorizontalExpandingAnimation(
visible = arrowDirection == Direction.START,
enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn() + slideInHorizontally { -it },
exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut() + slideOutHorizontally { -it }
startDirection = true
) {
CustomIconButton(
icon = Icons.AutoMirrored.Default.ArrowBack,
@ -127,6 +126,47 @@ fun ReaderBottomBar() {
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart
) {
BottomBarSlider()
if (arrowDirection != Direction.NEUTRAL) {
SliderIndicator(progress = checkpointProgress)
}
}
HorizontalExpandingAnimation(
visible = arrowDirection == Direction.END,
startDirection = false
) {
CustomIconButton(
icon = Icons.AutoMirrored.Default.ArrowForward,
contentDescription = R.string.checkpoint_forward_content_desc,
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.primary,
disableOnClick = false
) {
onEvent(
ReaderEvent.OnRestoreCheckpoint { book ->
onLibraryEvent(LibraryEvent.OnUpdateBook(book))
onHistoryEvent(HistoryEvent.OnUpdateBook(book))
}
)
}
}
}
}
}
/**
* Bottom Bar Slider.
* Has semi-transparent track color.
*/
@Composable
private fun BottomBarSlider() {
val state = LocalReaderViewModel.current.state
val onEvent = LocalReaderViewModel.current.onEvent
val onLibraryEvent = LocalLibraryViewModel.current.onEvent
val onHistoryEvent = LocalHistoryViewModel.current.onEvent
Slider(
value = state.value.book.progress,
enabled = !state.value.lockMenu,
@ -153,10 +193,17 @@ fun ReaderBottomBar() {
disabledInactiveTrackColor = MaterialTheme.colorScheme.secondary.copy(0.15f),
)
)
if (arrowDirection != Direction.NEUTRAL) {
}
/**
* Slider Indicator.
* Shows an indicator at desired progress.
*/
@Composable
private fun SliderIndicator(progress: Float) {
Row(Modifier.fillMaxWidth()) {
Spacer(
modifier = Modifier.fillMaxWidth(checkpointProgress)
modifier = Modifier.fillMaxWidth(progress)
)
Box(
Modifier
@ -169,28 +216,44 @@ fun ReaderBottomBar() {
)
}
}
/**
* Horizontal Expanding Animation.
*/
@Composable
private fun HorizontalExpandingAnimation(
visible: Boolean,
startDirection: Boolean,
content: @Composable () -> Unit,
) {
val enterAnimation = remember(startDirection) {
when (startDirection) {
true -> {
expandHorizontally(expandFrom = Alignment.Start) + fadeIn() + slideInHorizontally { -it }
}
false -> {
expandHorizontally() + fadeIn() + slideInHorizontally { it }
}
}
}
val exitAnimation = remember(startDirection) {
when (startDirection) {
true -> {
shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut() + slideOutHorizontally { -it }
}
false -> {
shrinkHorizontally() + fadeOut() + slideOutHorizontally { it }
}
}
}
CustomAnimatedVisibility(
visible = arrowDirection == Direction.END,
enter = expandHorizontally() + fadeIn() + slideInHorizontally { it },
exit = shrinkHorizontally() + fadeOut() + slideOutHorizontally { it }
visible = visible,
enter = enterAnimation,
exit = exitAnimation
) {
CustomIconButton(
icon = Icons.AutoMirrored.Default.ArrowForward,
contentDescription = R.string.checkpoint_forward_content_desc,
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.primary,
disableOnClick = false
) {
onEvent(
ReaderEvent.OnRestoreCheckpoint { book ->
onLibraryEvent(LibraryEvent.OnUpdateBook(book))
onHistoryEvent(HistoryEvent.OnUpdateBook(book))
}
)
}
}
}
content()
}
}

View file

@ -1,19 +1,25 @@
package ua.acclorite.book_story.presentation.screens.reader.components.app_bar
import androidx.activity.ComponentActivity
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@ -45,6 +51,15 @@ fun ReaderTopBar() {
val context = LocalContext.current as ComponentActivity
val onNavigate = LocalOnNavigate.current
val animatedChapterProgress = animateFloatAsState(
targetValue = state.value.currentChapterProgress
)
Column(
Modifier
.fillMaxWidth()
.background(Colors.readerSystemBarsColor)
) {
TopAppBar(
navigationIcon = {
CustomIconButton(
@ -107,7 +122,8 @@ fun ReaderTopBar() {
)
)
Text(
state.value.book.author.asString(),
state.value.currentChapter?.title
?: context.getString(R.string.no_chapters),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
@ -116,17 +132,40 @@ fun ReaderTopBar() {
}
},
actions = {
if (state.value.currentChapter != null) {
CustomIconButton(
icon = Icons.Rounded.Menu,
contentDescription = R.string.chapters_content_desc,
disableOnClick = false,
enabled = !state.value.lockMenu &&
!state.value.showChaptersDrawer &&
!state.value.showSettingsBottomSheet
) {
onEvent(ReaderEvent.OnShowHideChaptersDrawer(true))
}
}
CustomIconButton(
icon = Icons.Default.Settings,
contentDescription = R.string.open_reader_settings_content_desc,
disableOnClick = false,
enabled = !state.value.lockMenu
enabled = !state.value.lockMenu &&
!state.value.showChaptersDrawer &&
!state.value.showSettingsBottomSheet
) {
onEvent(ReaderEvent.OnShowHideSettingsBottomSheet)
onEvent(ReaderEvent.OnShowHideSettingsBottomSheet(true))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Colors.readerSystemBarsColor
containerColor = Color.Transparent
)
)
if (state.value.currentChapter != null) {
LinearProgressIndicator(
progress = { animatedChapterProgress.value },
modifier = Modifier.fillMaxWidth()
)
}
}
}

View file

@ -79,7 +79,7 @@ fun ReaderSettingsBottomSheet() {
.fillMaxHeight(animatedHeight),
dragHandle = {},
onDismissRequest = {
onEvent(ReaderEvent.OnShowHideSettingsBottomSheet)
onEvent(ReaderEvent.OnShowHideSettingsBottomSheet(false))
}
) {
ReaderSettingsBottomSheetTabRow(pagerState = pagerState)

View file

@ -9,6 +9,7 @@ import ua.acclorite.book_story.domain.util.UIText
@Immutable
sealed class ReaderEvent {
data object OnTextIsEmpty : ReaderEvent()
data class OnLoadText(
val refreshList: (Book) -> Unit,
val onError: (UIText) -> Unit,
@ -39,9 +40,28 @@ sealed class ReaderEvent {
val refreshList: (Book) -> Unit
) : ReaderEvent()
data class OnScroll(val progress: Float) : ReaderEvent()
data object OnShowHideSettingsBottomSheet : ReaderEvent()
data class OnScrollToSettingsPage(val page: Int, val pagerState: PagerState?) : ReaderEvent()
data class OnScroll(
val progress: Float
) : ReaderEvent()
data class OnScrollToChapter(
val chapterStartIndex: Int,
val refreshList: (Book) -> Unit
) : ReaderEvent()
data class OnShowHideSettingsBottomSheet(
val show: Boolean
) : ReaderEvent()
data class OnShowHideChaptersDrawer(
val show: Boolean
) : ReaderEvent()
data class OnScrollToSettingsPage(
val page: Int,
val pagerState: PagerState?
) : ReaderEvent()
data class OnOpenTranslator(
val textToTranslate: String,
val translateWholeParagraph: Boolean,

View file

@ -3,6 +3,7 @@ package ua.acclorite.book_story.presentation.screens.reader.data
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Chapter
import ua.acclorite.book_story.domain.util.UIText
import ua.acclorite.book_story.presentation.core.constants.Constants
@ -12,6 +13,10 @@ data class ReaderState(
val text: List<String> = emptyList(),
val listState: LazyListState = LazyListState(),
val currentChapter: Chapter? = null,
val currentChapterProgress: Float = 0f,
val showChaptersDrawer: Boolean = false,
val errorMessage: UIText? = null,
val loading: Boolean = true,

View file

@ -3,7 +3,6 @@ package ua.acclorite.book_story.presentation.screens.reader.data
import android.app.SearchManager
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.compose.runtime.snapshotFlow
import androidx.core.view.WindowCompat
@ -13,6 +12,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@ -28,7 +28,7 @@ import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.use_case.GetBookById
import ua.acclorite.book_story.domain.use_case.GetLatestHistory
import ua.acclorite.book_story.domain.use_case.GetText
import ua.acclorite.book_story.domain.use_case.UpdateBooks
import ua.acclorite.book_story.domain.use_case.UpdateBook
import ua.acclorite.book_story.domain.util.OnNavigate
import ua.acclorite.book_story.domain.util.UIText
import ua.acclorite.book_story.presentation.core.navigation.Screen
@ -40,7 +40,7 @@ import kotlin.math.roundToInt
@Suppress("LABEL_NAME_CLASH")
@HiltViewModel
class ReaderViewModel @Inject constructor(
private val updateBooks: UpdateBooks,
private val updateBook: UpdateBook,
private val getText: GetText,
private val getLatestHistory: GetLatestHistory,
private val getBookById: GetBookById
@ -50,6 +50,7 @@ class ReaderViewModel @Inject constructor(
override val state = _state.asStateFlow()
private var eventJob = SupervisorJob()
private var scrollJob: Job? = null
override fun onEvent(event: ReaderEvent) {
viewModelScope.launch(eventJob + Dispatchers.Main) {
@ -73,60 +74,26 @@ class ReaderViewModel @Inject constructor(
_state.update {
it.copy(
book = it.book.copy(
lastOpened = getLatestHistory.execute(_state.value.book.id)?.time
),
text = text
)
}
val history = _state.value.book.id.let {
getLatestHistory.execute(
it
)
}
_state.update {
it.copy(
book = it.book.copy(
lastOpened = history?.time,
)
)
}
updateBooks.execute(
listOf(_state.value.book)
)
updateBook.execute(_state.value.book)
event.refreshList(_state.value.book)
launch {
snapshotFlow {
_state.value.listState.layoutInfo.totalItemsCount
}.collectLatest { itemsCount ->
val index = _state.value.book.scrollIndex
val offset = _state.value.book.scrollOffset
if (itemsCount < _state.value.text.size) return@collectLatest
if (itemsCount >= _state.value.text.size) {
if (index > 0 || offset > 0) {
var loaded = false
for (i in 1..100) {
try {
_state.value.listState.requestScrollToItem(
index,
offset
_state.value.book.scrollIndex,
_state.value.book.scrollOffset
)
loaded = true
break
} catch (e: Exception) {
Log.w(
"READER",
"Couldn't scroll to desired index and offset"
)
delay(100)
}
}
if (!loaded) {
event.onTextIsEmpty()
}
}
delay(100)
_state.update {
@ -140,7 +107,6 @@ class ReaderViewModel @Inject constructor(
}
}
}
}
is ReaderEvent.OnShowHideMenu -> {
launch {
@ -171,7 +137,7 @@ class ReaderViewModel @Inject constructor(
}
is ReaderEvent.OnRestoreCheckpoint -> {
launch(Dispatchers.Main) {
launch {
_state.value.listState.requestScrollToItem(
_state.value.checkpoint.first,
_state.value.checkpoint.second
@ -217,9 +183,7 @@ class ReaderViewModel @Inject constructor(
)
}
updateBooks.execute(
listOf(_state.value.book)
)
updateBook.execute(_state.value.book)
event.refreshList(_state.value.book)
}
@ -233,11 +197,30 @@ class ReaderViewModel @Inject constructor(
}
is ReaderEvent.OnScroll -> {
launch {
val scrollTo = (_state.value.text.size * event.progress).roundToInt()
scrollJob?.cancel()
scrollJob = launch {
delay(300)
_state.value.listState.scrollToItem(
scrollTo
yield()
val scrollTo = (_state.value.text.size * event.progress).roundToInt()
_state.value.listState.requestScrollToItem(scrollTo)
}
}
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,
refreshList = event.refreshList
)
)
}
}
@ -254,28 +237,32 @@ class ReaderViewModel @Inject constructor(
)
}
updateBooks.execute(
listOf(_state.value.book)
)
updateBook.execute(_state.value.book)
event.refreshList(_state.value.book)
}
}
is ReaderEvent.OnShowHideSettingsBottomSheet -> {
launch(Dispatchers.IO) {
_state.update {
it.copy(
showSettingsBottomSheet = !it.showSettingsBottomSheet
showSettingsBottomSheet = event.show
)
}
}
is ReaderEvent.OnShowHideChaptersDrawer -> {
_state.update {
it.copy(
showChaptersDrawer = event.show
)
}
}
is ReaderEvent.OnScrollToSettingsPage -> {
launch {
_state.update {
event.pagerState?.scrollToPage(event.page)
event.pagerState?.requestScrollToPage(event.page)
_state.update {
it.copy(
currentPage = event.page
)
@ -465,7 +452,7 @@ class ReaderViewModel @Inject constructor(
@OptIn(FlowPreview::class)
fun onUpdateProgress(refreshList: (Book) -> Unit) {
viewModelScope.launch {
viewModelScope.launch(Dispatchers.IO) {
snapshotFlow {
_state.value.listState.firstVisibleItemIndex to _state.value.listState.firstVisibleItemScrollOffset
}
@ -490,6 +477,49 @@ class ReaderViewModel @Inject constructor(
}
}
@OptIn(FlowPreview::class)
fun onUpdateCurrentChapter() {
viewModelScope.launch(Dispatchers.IO) {
snapshotFlow {
_state.value.listState.firstVisibleItemIndex
}
.distinctUntilChanged()
.debounce(300)
.collectLatest { index ->
if (_state.value.book.chapters.size < 2) {
_state.update {
it.copy(
currentChapter = null
)
}
return@collectLatest
}
updateChapter(index)
}
}
}
private fun updateChapter(index: Int) {
val currentChapter = _state.value.book.chapters.find { chapter ->
index in chapter.startIndex..chapter.endIndex
}
val currentChapterProgress = currentChapter.run {
if (this == null) return@run 0f
val currentIndex = index - startIndex
val endIndex = endIndex - startIndex
currentIndex / endIndex.toFloat()
}
_state.update {
it.copy(
currentChapter = currentChapter,
currentChapterProgress = currentChapterProgress
)
}
}
private fun calculateProgress(firstVisibleItemIndex: Int? = null): Float {
return _state.value.run {
if (

View file

@ -352,6 +352,8 @@
<!-- Reader -->
<string name="loading">Завантаження…</string>
<string name="no_chapters">Немає розділів</string>
<string name="chapters">Розділи</string>
<!-- About -->
<string name="app_version_option">Версія застосунка</string>
@ -596,5 +598,6 @@
<string name="filter_content_desc">Фільтр</string>
<string name="checkpoint_back_content_desc">Чекпоінт назад</string>
<string name="checkpoint_forward_content_desc">Чекпоінт вперед</string>
<string name="chapters_content_desc">Розділи</string>
</resources>

View file

@ -355,6 +355,8 @@
<!-- Reader -->
<string name="loading">Loading…</string>
<string name="no_chapters">No chapters</string>
<string name="chapters">Chapters</string>
<!-- About -->
<string name="app_version_option">App version</string>
@ -601,5 +603,6 @@
<string name="filter_content_desc">Filter</string>
<string name="checkpoint_back_content_desc">Checkpoint back</string>
<string name="checkpoint_forward_content_desc">Checkpoint forward</string>
<string name="chapters_content_desc">Chapters</string>
</resources>