🚀 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 // Scrollbar
implementation("com.github.nanihadesuka:LazyColumnScrollbar:2.2.0") 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 package ua.acclorite.book_story.data.local.dto
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey 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.Category
import ua.acclorite.book_story.domain.model.Chapter
@Entity @Entity
@TypeConverters(ChapterConverter::class)
data class BookEntity( data class BookEntity(
@PrimaryKey(true) val id: Int = 0, @PrimaryKey(true) val id: Int = 0,
val title: String, val title: String,
val author: String?, val author: String?,
val description: String?, val description: String?,
val textPath: String, val textPath: String,
@ColumnInfo(defaultValue = "[]")
val chapters: List<Chapter>,
val filePath: String, val filePath: String,
val scrollIndex: Int, val scrollIndex: Int,
val scrollOffset: Int, val scrollOffset: Int,

View file

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

View file

@ -20,7 +20,8 @@ class BookMapperImpl @Inject constructor() : BookMapper {
textPath = book.textPath, textPath = book.textPath,
description = book.description, description = book.description,
image = if (book.coverImage != null) book.coverImage.toString() else null, 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, lastOpened = null,
category = bookEntity.category, category = bookEntity.category,
coverImage = if (bookEntity.image != null) Uri.parse(bookEntity.image) else null, 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 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.epub.EpubFileParser
import ua.acclorite.book_story.data.parser.fb2.Fb2FileParser 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.htm.HtmFileParser
import ua.acclorite.book_story.data.parser.html.HtmlFileParser 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.pdf.PdfFileParser
import ua.acclorite.book_story.data.parser.txt.TxtFileParser 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.model.Book
import ua.acclorite.book_story.domain.util.CoverImage import ua.acclorite.book_story.domain.util.CoverImage
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
private const val FILE_PARSER = "File Parser"
class FileParserImpl @Inject constructor( class FileParserImpl @Inject constructor(
private val txtFileParser: TxtFileParser, private val txtFileParser: TxtFileParser,
private val pdfFileParser: PdfFileParser, private val pdfFileParser: PdfFileParser,
private val epubFileParser: EpubFileParser, private val epubFileParser: EpubFileParser,
private val fb2FileParser: Fb2FileParser, private val fb2FileParser: Fb2FileParser,
private val zipFileParser: ZipFileParser,
private val htmlFileParser: HtmlFileParser, private val htmlFileParser: HtmlFileParser,
private val htmFileParser: HtmFileParser, private val htmFileParser: HtmFileParser,
) : FileParser { ) : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? { override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.exists()) { if (!file.exists()) {
Log.e(FILE_PARSER, "File does not exist.")
return null return null
} }
@ -45,7 +47,7 @@ class FileParserImpl @Inject constructor(
} }
".zip" -> { ".zip" -> {
zipFileParser.parse(file) epubFileParser.parse(file)
} }
".html" -> { ".html" -> {
@ -56,7 +58,10 @@ class FileParserImpl @Inject constructor(
htmFileParser.parse(file) 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 package ua.acclorite.book_story.data.parser
import ua.acclorite.book_story.domain.model.ChapterWithText
import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.Resource
import java.io.File import java.io.File
interface TextParser { 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 package ua.acclorite.book_story.data.parser
import android.util.Log
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.epub.EpubTextParser 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.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.html.HtmlTextParser
import ua.acclorite.book_story.data.parser.pdf.PdfTextParser 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.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.Resource
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
private const val TEXT_PARSER = "Text Parser"
class TextParserImpl @Inject constructor( class TextParserImpl @Inject constructor(
private val txtTextParser: TxtTextParser, private val txtTextParser: TxtTextParser,
private val pdfTextParser: PdfTextParser, private val pdfTextParser: PdfTextParser,
private val epubTextParser: EpubTextParser, private val epubTextParser: EpubTextParser,
private val fb2TextParser: Fb2TextParser, private val fb2TextParser: Fb2TextParser,
private val zipTextParser: ZipTextParser,
private val htmlTextParser: HtmlTextParser, private val htmlTextParser: HtmlTextParser,
private val htmTextParser: HtmTextParser, private val htmTextParser: HtmTextParser,
) : TextParser { ) : TextParser {
override suspend fun parse(file: File): Resource<List<String>> { override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
if (!file.exists()) { if (!file.exists()) {
Log.e(TEXT_PARSER, "File does not exist.")
return Resource.Error( return Resource.Error(
UIText.StringResource(R.string.error_something_went_wrong_with_file) UIText.StringResource(R.string.error_something_went_wrong_with_file)
) )
@ -48,7 +51,7 @@ class TextParserImpl @Inject constructor(
} }
".zip" -> { ".zip" -> {
zipTextParser.parse(file) epubTextParser.parse(file)
} }
".html" -> { ".html" -> {
@ -59,7 +62,10 @@ class TextParserImpl @Inject constructor(
htmTextParser.parse(file) 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 { class EpubFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? { override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.name.endsWith(".epub", true) || !file.exists()) { return try {
return null
}
try {
var book: Pair<Book, CoverImage?>? = null var book: Pair<Book, CoverImage?>? = null
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -90,15 +86,14 @@ class EpubFileParser @Inject constructor() : FileParser {
) to extractCoverImageBitmap(file, coverImage) ) to extractCoverImageBitmap(file, coverImage)
} }
} }
return book book
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
return null null
} }
} }
}
private fun extractCoverImageBitmap(file: File, coverImagePath: String?): Bitmap? { private fun extractCoverImageBitmap(file: File, coverImagePath: String?): Bitmap? {
if (coverImagePath.isNullOrBlank()) { if (coverImagePath.isNullOrBlank()) {
return null return null
} }
@ -113,4 +108,5 @@ private fun extractCoverImageBitmap(file: File, coverImagePath: String?): Bitmap
} }
return null return null
}
} }

View file

@ -1,64 +1,63 @@
package ua.acclorite.book_story.data.parser.epub package ua.acclorite.book_story.data.parser.epub
import android.net.Uri
import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jsoup.Jsoup import org.jsoup.Jsoup
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser 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.Resource
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
import java.io.File import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
import javax.inject.Inject import javax.inject.Inject
private const val EPUB_TAG = "EPUB Parser"
class EpubTextParser @Inject constructor() : TextParser { class EpubTextParser @Inject constructor() : TextParser {
override suspend fun parse(file: File): Resource<List<String>> { override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
if (!file.name.endsWith(".epub", true) || !file.exists()) { Log.i(EPUB_TAG, "Started EPUB parsing: ${file.name}.")
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
try { return try {
val lines = mutableListOf<String>() val chapters = mutableListOf<ChapterWithText>()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
ZipFile(file).use { zip -> ZipFile(file).use { zip ->
zip.entries().asSequence().forEach { entry -> zip.entries().asSequence().find { entry ->
if ( entry.name.endsWith("toc.ncx", ignoreCase = true)
entry.name.endsWith(".xhtml") }.apply {
|| entry.name.endsWith(".html") if (this == null) {
|| entry.name.endsWith(".xml") Log.w(EPUB_TAG, "toc.ncx was not found.")
|| entry.name.endsWith(".htm") parseWithoutToc(zip)?.let { chapters.addAll(it) }
) { return@withContext
val content = zip.getInputStream(entry).bufferedReader()
.use {
it.readText()
} }
val document = Jsoup.parse(content) parseWithToc(this, zip).apply {
document.select("p").append("\n") if (this == null) {
document parseWithoutToc(zip)?.let { chapters.addAll(it) }
.wholeText() return@withContext
.lines()
.forEach { line ->
if (line.isNotBlank()) {
lines.add(line.trim())
}
} }
chapters.addAll(this)
} }
} }
} }
} }
if (lines.isEmpty()) { if (chapters.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty)) 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) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
return Resource.Error( Resource.Error(
UIText.StringResource( UIText.StringResource(
R.string.error_query, R.string.error_query,
e.message?.take(40)?.trim() ?: "" 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 { class Fb2FileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? { override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
if (!file.name.endsWith(".fb2", true) || !file.exists()) { return try {
return null
}
try {
val factory = DocumentBuilderFactory.newInstance() val factory = DocumentBuilderFactory.newInstance()
val builder = factory.newDocumentBuilder() val builder = factory.newDocumentBuilder()
val document = withContext(Dispatchers.IO) { val document = withContext(Dispatchers.IO) {
@ -54,7 +50,7 @@ class Fb2FileParser @Inject constructor() : FileParser {
val descriptionFromFile = extractElementContent(document, "annotation") val descriptionFromFile = extractElementContent(document, "annotation")
return Book( Book(
title = title, title = title,
author = author, author = author,
description = descriptionFromFile, description = descriptionFromFile,
@ -69,7 +65,7 @@ class Fb2FileParser @Inject constructor() : FileParser {
) to null ) to null
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
return null null
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,41 +1,52 @@
package ua.acclorite.book_story.data.parser.txt package ua.acclorite.book_story.data.parser.txt
import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser 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.Resource
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
import ua.acclorite.book_story.presentation.core.constants.Constants
import java.io.BufferedReader import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.FileReader import java.io.FileReader
import javax.inject.Inject import javax.inject.Inject
private const val TXT_TAG = "TXT Parser"
class TxtTextParser @Inject constructor() : TextParser { class TxtTextParser @Inject constructor() : TextParser {
override suspend fun parse(file: File): Resource<List<String>> { override suspend fun parse(file: File): Resource<List<ChapterWithText>> {
if (!file.name.endsWith(".txt", true) || !file.exists()) { Log.i(TXT_TAG, "Started TXT parsing: ${file.name}.")
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
return try { return try {
val formattedLines = mutableListOf<String>() val lines = mutableListOf<String>()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
BufferedReader(FileReader(file)).forEachLine { line -> BufferedReader(FileReader(file)).forEachLine { line ->
if (line.isNotBlank()) { if (line.isNotBlank()) {
formattedLines.add( lines.add(
line.trim() line.trim()
) )
} }
} }
} }
if (formattedLines.isEmpty()) { if (lines.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty)) 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) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
Resource.Error( 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.domain.util.UIText
import ua.acclorite.book_story.presentation.core.constants.Constants import ua.acclorite.book_story.presentation.core.constants.Constants
import ua.acclorite.book_story.presentation.data.MainState import ua.acclorite.book_story.presentation.data.MainState
import java.io.BufferedReader
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.FileReader
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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") @Suppress("DEPRECATION")
@Singleton @Singleton
class BookRepositoryImpl @Inject constructor( 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> { override suspend fun getBookText(textPath: String): List<String> {
val textFile = File(textPath) val textFile = File(textPath)
val lines = mutableListOf<String>()
if (textPath.isBlank() || !textFile.exists()) { if (textPath.isBlank() || !textFile.exists() || textFile.extension != "txt") {
Log.w("BOOK TEXT", "Failed to load file") Log.w(GET_TEXT, "Failed to load file: $textPath")
return emptyList() return emptyList()
} }
val text = textParser.parse(textFile) withContext(Dispatchers.IO) {
BufferedReader(FileReader(textFile)).forEachLine { line ->
if (text.data.isNullOrEmpty()) { if (line.isNotBlank()) {
return emptyList() lines.add(
line.trim()
)
}
}
} }
return text.data Log.i(GET_TEXT, "Successfully loaded text.")
return lines
} }
override suspend fun insertBook( override suspend fun insertBook(
@ -555,74 +570,40 @@ class BookRepositoryImpl @Inject constructor(
return rootDirectory.getAllFiles() return rootDirectory.getAllFiles()
} }
override suspend fun getBooksFromFiles(files: List<File>): List<NullableBook> { /**
val books = mutableListOf<NullableBook>() * Gets book from given file. If error happened, returns [NullableBook.Null].
*/
for (file in files) { override suspend fun getBookFromFile(file: File): NullableBook {
val parsedBook = if (Constants.EXTENSIONS.any { file.name.endsWith(it, true) }) { val parsedBook = fileParser.parse(file)
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
}
if (parsedBook == null) { if (parsedBook == null) {
books.add( Log.w(GET_BOOK_FROM_FILE, "Parsed book(${file.name}) is null.")
NullableBook.Null( return NullableBook.Null(
file.name, 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) { if (parsedText is Resource.Error) {
books.add( Log.w(GET_BOOK_FROM_FILE, "Parsed text(${file.name}) has error.")
NullableBook.Null( return NullableBook.Null(
file.name, file.name,
parsedText.message parsedText.message
) )
)
continue
} }
if (parsedText.data == null) { return NullableBook.NotNull(
books.add( book = parsedBook.first.copy(
NullableBook.Null( chapters = parsedText.data!!.map { it.chapter }.run {
file.name, if (this.size == 1) return@run emptyList()
UIText.StringResource(R.string.error_file_empty) this
)
)
continue
} }
),
books.add(
NullableBook.NotNull(
book = parsedBook.first,
coverImage = parsedBook.second, coverImage = parsedBook.second,
text = parsedText.data text = parsedText.data.map {
it.text
}.flatten()
) )
)
}
return books
} }
override suspend fun insertHistory(history: List<History>) { override suspend fun insertHistory(history: List<History>) {

View file

@ -19,6 +19,7 @@ data class Book(
val scrollIndex: Int, val scrollIndex: Int,
val scrollOffset: Int, val scrollOffset: Int,
val progress: Float, val progress: Float,
val chapters: List<Chapter> = emptyList(),
val lastOpened: Long?, val lastOpened: Long?,
val category: Category, 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 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>) suspend fun insertHistory(history: List<History>)

View file

@ -10,6 +10,6 @@ class GetBookFromFile @Inject constructor(
) { ) {
suspend fun execute(file: File): NullableBook { 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 ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Inject 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>) { suspend fun execute(book: Book) {
repository.updateBooks(books) 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.Badge
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category 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.ColorPreset
import ua.acclorite.book_story.domain.model.Credit import ua.acclorite.book_story.domain.model.Credit
import ua.acclorite.book_story.domain.model.FontWithName import ua.acclorite.book_story.domain.model.FontWithName
@ -64,6 +65,9 @@ object Constants {
// Default Color Preset (Used when creating new color presets) // Default Color Preset (Used when creating new color presets)
val DEFAULT_COLOR_PRESET = provideDefaultColorPreset() val DEFAULT_COLOR_PRESET = provideDefaultColorPreset()
// Empty Chapter
val EMPTY_CHAPTER = provideEmptyChapter()
// Scrollbars (Primary unused, awaiting for fixes) // Scrollbars (Primary unused, awaiting for fixes)
val PRIMARY_SCROLLBAR @Composable get() = providePrimaryScrollbar() val PRIMARY_SCROLLBAR @Composable get() = providePrimaryScrollbar()
val SECONDARY_SCROLLBAR @Composable get() = provideSecondaryScrollbar() val SECONDARY_SCROLLBAR @Composable get() = provideSecondaryScrollbar()
@ -582,6 +586,13 @@ private fun provideDefaultColorPreset() = ColorPreset(
isSelected = false isSelected = false
) )
private fun provideEmptyChapter() = Chapter(
index = 0,
title = "",
startIndex = 0,
endIndex = 0
)
@Composable @Composable
private fun providePrimaryScrollbar() = ScrollbarSettings( private fun providePrimaryScrollbar() = ScrollbarSettings(
thumbUnselectedColor = MaterialTheme.colorScheme.primary, 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.GetText
import ua.acclorite.book_story.domain.use_case.InsertHistory 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.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.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.use_case.UpdateCoverImageOfBook
import ua.acclorite.book_story.domain.util.OnNavigate import ua.acclorite.book_story.domain.util.OnNavigate
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
@ -44,7 +44,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BookInfoViewModel @Inject constructor( class BookInfoViewModel @Inject constructor(
private val updateBooks: UpdateBooks, private val updateBook: UpdateBook,
private val updateBookWithText: UpdateBookWithText, private val updateBookWithText: UpdateBookWithText,
private val updateCoverImageOfBook: UpdateCoverImageOfBook, private val updateCoverImageOfBook: UpdateCoverImageOfBook,
private val insertHistory: InsertHistory, private val insertHistory: InsertHistory,
@ -307,7 +307,7 @@ class BookInfoViewModel @Inject constructor(
else _state.value.book.description else _state.value.book.description
) )
updateBooks.execute(listOf(book)) updateBook.execute(book)
_state.update { _state.update {
it.copy( it.copy(
book = book 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.refreshList(_state.value.book)
event.updatePage( 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.DeleteBooks
import ua.acclorite.book_story.domain.use_case.GetBooks 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.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.navigation.Screen
import ua.acclorite.book_story.presentation.core.util.BaseViewModel import ua.acclorite.book_story.presentation.core.util.BaseViewModel
import java.util.Date import java.util.Date
@ -25,7 +25,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LibraryViewModel @Inject constructor( class LibraryViewModel @Inject constructor(
private val getBooks: GetBooks, private val getBooks: GetBooks,
private val updateBooks: UpdateBooks, private val updateBook: UpdateBook,
private val deleteBooks: DeleteBooks, private val deleteBooks: DeleteBooks,
private val insertHistory: InsertHistory private val insertHistory: InsertHistory
) : BaseViewModel<LibraryState, LibraryEvent>() { ) : BaseViewModel<LibraryState, LibraryEvent>() {
@ -240,7 +240,7 @@ class LibraryViewModel @Inject constructor(
val books = _state.value.books.filter { val books = _state.value.books.filter {
it.second it.second
}.map { it.first.copy(category = _state.value.selectedCategory) } }.map { it.first.copy(category = _state.value.selectedCategory) }
updateBooks.execute(books) books.forEach { updateBook.execute(it) }
_state.update { _state.update {
it.copy( it.copy(

View file

@ -60,6 +60,7 @@ import androidx.core.view.WindowInsetsCompat
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ua.acclorite.book_story.R 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.CustomAnimatedVisibility
import ua.acclorite.book_story.presentation.core.components.CustomSelectionContainer import ua.acclorite.book_story.presentation.core.components.CustomSelectionContainer
import ua.acclorite.book_story.presentation.core.components.LocalHistoryViewModel 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.core.util.showToast
import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent 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.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.ReaderTextParagraph
import ua.acclorite.book_story.presentation.screens.reader.components.app_bar.ReaderBottomBar import ua.acclorite.book_story.presentation.screens.reader.components.app_bar.ReaderBottomBar
import ua.acclorite.book_story.presentation.screens.reader.components.app_bar.ReaderTopBar 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)) onLibraryEvent(LibraryEvent.OnUpdateBook(it))
onHistoryEvent(HistoryEvent.OnUpdateBook(it)) onHistoryEvent(HistoryEvent.OnUpdateBook(it))
} }
viewModel.onUpdateCurrentChapter()
} }
ReaderScreen(lazyListState = lazyListState) 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) { if (state.value.showSettingsBottomSheet) {
ReaderSettingsBottomSheet() ReaderSettingsBottomSheet()
} }
@ -460,7 +473,13 @@ private fun ReaderScreen(lazyListState: LazyListState) {
) { ) {
customItemsIndexed( customItemsIndexed(
state.value.text, key = { _, index -> index } 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( ReaderTextParagraph(
line = line, line = line,
context = context, context = context,
@ -531,6 +550,9 @@ private fun ReaderScreen(lazyListState: LazyListState) {
} }
} }
// Drawers
ReaderChaptersDrawer()
BackHandler { BackHandler {
onEvent( onEvent(
ReaderEvent.OnGoBack( 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), modifier = Modifier.padding(top = 3.dp, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
CustomAnimatedVisibility( HorizontalExpandingAnimation(
visible = arrowDirection == Direction.START, visible = arrowDirection == Direction.START,
enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn() + slideInHorizontally { -it }, startDirection = true
exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut() + slideOutHorizontally { -it }
) { ) {
CustomIconButton( CustomIconButton(
icon = Icons.AutoMirrored.Default.ArrowBack, icon = Icons.AutoMirrored.Default.ArrowBack,
@ -127,6 +126,47 @@ fun ReaderBottomBar() {
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart 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( Slider(
value = state.value.book.progress, value = state.value.book.progress,
enabled = !state.value.lockMenu, enabled = !state.value.lockMenu,
@ -153,10 +193,17 @@ fun ReaderBottomBar() {
disabledInactiveTrackColor = MaterialTheme.colorScheme.secondary.copy(0.15f), 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()) { Row(Modifier.fillMaxWidth()) {
Spacer( Spacer(
modifier = Modifier.fillMaxWidth(checkpointProgress) modifier = Modifier.fillMaxWidth(progress)
) )
Box( Box(
Modifier Modifier
@ -168,29 +215,45 @@ 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( CustomAnimatedVisibility(
visible = arrowDirection == Direction.END, visible = visible,
enter = expandHorizontally() + fadeIn() + slideInHorizontally { it }, enter = enterAnimation,
exit = shrinkHorizontally() + fadeOut() + slideOutHorizontally { it } exit = exitAnimation
) { ) {
CustomIconButton( content()
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))
}
)
}
}
}
} }
} }

View file

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

View file

@ -9,6 +9,7 @@ import ua.acclorite.book_story.domain.util.UIText
@Immutable @Immutable
sealed class ReaderEvent { sealed class ReaderEvent {
data object OnTextIsEmpty : ReaderEvent() data object OnTextIsEmpty : ReaderEvent()
data class OnLoadText( data class OnLoadText(
val refreshList: (Book) -> Unit, val refreshList: (Book) -> Unit,
val onError: (UIText) -> Unit, val onError: (UIText) -> Unit,
@ -39,9 +40,28 @@ sealed class ReaderEvent {
val refreshList: (Book) -> Unit val refreshList: (Book) -> Unit
) : ReaderEvent() ) : ReaderEvent()
data class OnScroll(val progress: Float) : ReaderEvent() data class OnScroll(
data object OnShowHideSettingsBottomSheet : ReaderEvent() val progress: Float
data class OnScrollToSettingsPage(val page: Int, val pagerState: PagerState?) : ReaderEvent() ) : 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( data class OnOpenTranslator(
val textToTranslate: String, val textToTranslate: String,
val translateWholeParagraph: Boolean, 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.foundation.lazy.LazyListState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.Book 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.domain.util.UIText
import ua.acclorite.book_story.presentation.core.constants.Constants import ua.acclorite.book_story.presentation.core.constants.Constants
@ -12,6 +13,10 @@ data class ReaderState(
val text: List<String> = emptyList(), val text: List<String> = emptyList(),
val listState: LazyListState = LazyListState(), val listState: LazyListState = LazyListState(),
val currentChapter: Chapter? = null,
val currentChapterProgress: Float = 0f,
val showChaptersDrawer: Boolean = false,
val errorMessage: UIText? = null, val errorMessage: UIText? = null,
val loading: Boolean = true, 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.app.SearchManager
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@ -13,6 +12,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow 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.GetBookById
import ua.acclorite.book_story.domain.use_case.GetLatestHistory 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.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.OnNavigate
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
import ua.acclorite.book_story.presentation.core.navigation.Screen import ua.acclorite.book_story.presentation.core.navigation.Screen
@ -40,7 +40,7 @@ import kotlin.math.roundToInt
@Suppress("LABEL_NAME_CLASH") @Suppress("LABEL_NAME_CLASH")
@HiltViewModel @HiltViewModel
class ReaderViewModel @Inject constructor( class ReaderViewModel @Inject constructor(
private val updateBooks: UpdateBooks, private val updateBook: UpdateBook,
private val getText: GetText, private val getText: GetText,
private val getLatestHistory: GetLatestHistory, private val getLatestHistory: GetLatestHistory,
private val getBookById: GetBookById private val getBookById: GetBookById
@ -50,6 +50,7 @@ class ReaderViewModel @Inject constructor(
override val state = _state.asStateFlow() override val state = _state.asStateFlow()
private var eventJob = SupervisorJob() private var eventJob = SupervisorJob()
private var scrollJob: Job? = null
override fun onEvent(event: ReaderEvent) { override fun onEvent(event: ReaderEvent) {
viewModelScope.launch(eventJob + Dispatchers.Main) { viewModelScope.launch(eventJob + Dispatchers.Main) {
@ -73,60 +74,26 @@ class ReaderViewModel @Inject constructor(
_state.update { _state.update {
it.copy( it.copy(
book = it.book.copy(
lastOpened = getLatestHistory.execute(_state.value.book.id)?.time
),
text = text text = text
) )
} }
val history = _state.value.book.id.let { updateBook.execute(_state.value.book)
getLatestHistory.execute(
it
)
}
_state.update {
it.copy(
book = it.book.copy(
lastOpened = history?.time,
)
)
}
updateBooks.execute(
listOf(_state.value.book)
)
event.refreshList(_state.value.book) event.refreshList(_state.value.book)
launch { launch {
snapshotFlow { snapshotFlow {
_state.value.listState.layoutInfo.totalItemsCount _state.value.listState.layoutInfo.totalItemsCount
}.collectLatest { itemsCount -> }.collectLatest { itemsCount ->
val index = _state.value.book.scrollIndex if (itemsCount < _state.value.text.size) return@collectLatest
val offset = _state.value.book.scrollOffset
if (itemsCount >= _state.value.text.size) {
if (index > 0 || offset > 0) {
var loaded = false
for (i in 1..100) {
try {
_state.value.listState.requestScrollToItem( _state.value.listState.requestScrollToItem(
index, _state.value.book.scrollIndex,
offset _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) delay(100)
_state.update { _state.update {
@ -140,7 +107,6 @@ class ReaderViewModel @Inject constructor(
} }
} }
} }
}
is ReaderEvent.OnShowHideMenu -> { is ReaderEvent.OnShowHideMenu -> {
launch { launch {
@ -171,7 +137,7 @@ class ReaderViewModel @Inject constructor(
} }
is ReaderEvent.OnRestoreCheckpoint -> { is ReaderEvent.OnRestoreCheckpoint -> {
launch(Dispatchers.Main) { launch {
_state.value.listState.requestScrollToItem( _state.value.listState.requestScrollToItem(
_state.value.checkpoint.first, _state.value.checkpoint.first,
_state.value.checkpoint.second _state.value.checkpoint.second
@ -217,9 +183,7 @@ class ReaderViewModel @Inject constructor(
) )
} }
updateBooks.execute( updateBook.execute(_state.value.book)
listOf(_state.value.book)
)
event.refreshList(_state.value.book) event.refreshList(_state.value.book)
} }
@ -233,11 +197,30 @@ class ReaderViewModel @Inject constructor(
} }
is ReaderEvent.OnScroll -> { is ReaderEvent.OnScroll -> {
launch { scrollJob?.cancel()
val scrollTo = (_state.value.text.size * event.progress).roundToInt() scrollJob = launch {
delay(300)
_state.value.listState.scrollToItem( yield()
scrollTo
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( updateBook.execute(_state.value.book)
listOf(_state.value.book)
)
event.refreshList(_state.value.book) event.refreshList(_state.value.book)
} }
} }
is ReaderEvent.OnShowHideSettingsBottomSheet -> { is ReaderEvent.OnShowHideSettingsBottomSheet -> {
launch(Dispatchers.IO) {
_state.update { _state.update {
it.copy( it.copy(
showSettingsBottomSheet = !it.showSettingsBottomSheet showSettingsBottomSheet = event.show
) )
} }
} }
is ReaderEvent.OnShowHideChaptersDrawer -> {
_state.update {
it.copy(
showChaptersDrawer = event.show
)
}
} }
is ReaderEvent.OnScrollToSettingsPage -> { is ReaderEvent.OnScrollToSettingsPage -> {
launch { launch {
_state.update { event.pagerState?.requestScrollToPage(event.page)
event.pagerState?.scrollToPage(event.page)
_state.update {
it.copy( it.copy(
currentPage = event.page currentPage = event.page
) )
@ -465,7 +452,7 @@ class ReaderViewModel @Inject constructor(
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
fun onUpdateProgress(refreshList: (Book) -> Unit) { fun onUpdateProgress(refreshList: (Book) -> Unit) {
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
snapshotFlow { snapshotFlow {
_state.value.listState.firstVisibleItemIndex to _state.value.listState.firstVisibleItemScrollOffset _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 { private fun calculateProgress(firstVisibleItemIndex: Int? = null): Float {
return _state.value.run { return _state.value.run {
if ( if (

View file

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

View file

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