diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a7d1d107..ebdaee7a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -166,4 +166,7 @@ dependencies { // Scrollbar implementation("com.github.nanihadesuka:LazyColumnScrollbar:2.2.0") + + // Gson + implementation("com.google.code.gson:gson:2.11.0") } \ No newline at end of file diff --git a/app/schemas/ua.acclorite.book_story.data.local.room.BookDatabase/7.json b/app/schemas/ua.acclorite.book_story.data.local.room.BookDatabase/7.json new file mode 100644 index 00000000..0e927e8b --- /dev/null +++ b/app/schemas/ua.acclorite.book_story.data.local.room.BookDatabase/7.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/converter/ChapterConverter.kt b/app/src/main/java/ua/acclorite/book_story/data/local/converter/ChapterConverter.kt new file mode 100644 index 00000000..249b7d4d --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/data/local/converter/ChapterConverter.kt @@ -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): String { + return Gson().toJson(chapters) + } + + @TypeConverter + fun toChapterList(data: String): List { + val listType = object : TypeToken>() {}.type + return try { + Gson().fromJson?>(data, listType).sortedBy { it.index } + } catch (j: JsonSyntaxException) { + j.printStackTrace() + emptyList() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/dto/BookEntity.kt b/app/src/main/java/ua/acclorite/book_story/data/local/dto/BookEntity.kt index 581eac47..620470c7 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/local/dto/BookEntity.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/local/dto/BookEntity.kt @@ -1,20 +1,27 @@ 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, val filePath: String, val scrollIndex: Int, val scrollOffset: Int, val progress: Float, val image: String? = null, val category: Category -) +) \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt b/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt index 6d13cd24..19c6b211 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt @@ -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` (" + diff --git a/app/src/main/java/ua/acclorite/book_story/data/mapper/book/BookMapperImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/mapper/book/BookMapperImpl.kt index e2953018..a2b59c7c 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/mapper/book/BookMapperImpl.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/mapper/book/BookMapperImpl.kt @@ -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 ) } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/FileParserImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/FileParserImpl.kt index 9e274f58..69426e34 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/FileParserImpl.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/FileParserImpl.kt @@ -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? { 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 + } } } } diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/TextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/TextParser.kt index 586e3dd5..ec9d35ac 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/TextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/TextParser.kt @@ -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> + suspend fun parse(file: File): Resource> } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/TextParserImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/TextParserImpl.kt index 44a9f343..1c39f552 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/TextParserImpl.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/TextParserImpl.kt @@ -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> { + override suspend fun parse(file: File): Resource> { 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)) + } } } } diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubFileParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubFileParser.kt index b58393d5..a590421e 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubFileParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubFileParser.kt @@ -18,11 +18,7 @@ import javax.inject.Inject class EpubFileParser @Inject constructor() : FileParser { override suspend fun parse(file: File): Pair? { - if (!file.name.endsWith(".epub", true) || !file.exists()) { - return null - } - - try { + return try { var book: Pair? = null withContext(Dispatchers.IO) { @@ -90,27 +86,27 @@ class EpubFileParser @Inject constructor() : FileParser { ) to extractCoverImageBitmap(file, coverImage) } } - return book + book } catch (e: Exception) { e.printStackTrace() + null + } + } + + private fun extractCoverImageBitmap(file: File, coverImagePath: String?): Bitmap? { + if (coverImagePath.isNullOrBlank()) { 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) + 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 + return null + } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt index 992f1efa..360f6eeb 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt @@ -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> { - 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> { + Log.i(EPUB_TAG, "Started EPUB parsing: ${file.name}.") - try { - val lines = mutableListOf() + return try { + val chapters = mutableListOf() 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? { + val chapters = mutableListOf() + 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? { + Log.i(EPUB_TAG, "TOC Entry: ${tocEntry.name}") + + val chapters = mutableListOf() + 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 { + val lines = mutableListOf() + 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 + } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/fb2/Fb2FileParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/fb2/Fb2FileParser.kt index 8fbf2a3f..5a1c5b88 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/fb2/Fb2FileParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/fb2/Fb2FileParser.kt @@ -17,11 +17,7 @@ import javax.xml.parsers.DocumentBuilderFactory class Fb2FileParser @Inject constructor() : FileParser { override suspend fun parse(file: File): Pair? { - 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 } } diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/fb2/Fb2TextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/fb2/Fb2TextParser.kt index 0a769ace..1cc74e05 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/fb2/Fb2TextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/fb2/Fb2TextParser.kt @@ -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> { - 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> { + 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() 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( diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/htm/HtmFileParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/htm/HtmFileParser.kt index 1afb80f4..cf08cd82 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/htm/HtmFileParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/htm/HtmFileParser.kt @@ -13,11 +13,7 @@ import javax.inject.Inject class HtmFileParser @Inject constructor() : FileParser { override suspend fun parse(file: File): Pair? { - 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 } } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/htm/HtmTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/htm/HtmTextParser.kt index 4d201b33..3206b63d 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/htm/HtmTextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/htm/HtmTextParser.kt @@ -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> { - 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> { + Log.i(HTM_TAG, "Started HTM parsing: ${file.name}.") - try { + return try { val lines = mutableListOf() 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() ?: "" diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/html/HtmlFileParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/html/HtmlFileParser.kt index be36e80b..5c686f75 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/html/HtmlFileParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/html/HtmlFileParser.kt @@ -13,11 +13,7 @@ import javax.inject.Inject class HtmlFileParser @Inject constructor() : FileParser { override suspend fun parse(file: File): Pair? { - 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 } } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/html/HtmlTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/html/HtmlTextParser.kt index ed21c264..4f4ced00 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/html/HtmlTextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/html/HtmlTextParser.kt @@ -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> { - 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> { + Log.i(HTML_TAG, "Started HTML parsing: ${file.name}.") - try { + return try { val lines = mutableListOf() 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() ?: "" diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfFileParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfFileParser.kt index b1b8c032..62294708 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfFileParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfFileParser.kt @@ -15,11 +15,7 @@ import javax.inject.Inject class PdfFileParser @Inject constructor(private val application: Application) : FileParser { override suspend fun parse(file: File): Pair? { - 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 } } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt index 8e79bd80..124c601a 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt @@ -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> { - 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> { + Log.i(PDF_TAG, "Started PDF parsing: ${file.name}.") + return try { val document = PDDocument.load(file) val strings = mutableListOf() @@ -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() ?: "" diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtFileParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtFileParser.kt index 074d4eca..bcfb4aa8 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtFileParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtFileParser.kt @@ -12,15 +12,11 @@ import javax.inject.Inject class TxtFileParser @Inject constructor() : FileParser { override suspend fun parse(file: File): Pair? { - 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 } } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt index 6fb2e617..42f38d92 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt @@ -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> { - 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> { + Log.i(TXT_TAG, "Started TXT parsing: ${file.name}.") return try { - val formattedLines = mutableListOf() + val lines = mutableListOf() 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( diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/zip/ZipFileParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/zip/ZipFileParser.kt deleted file mode 100644 index 6600897f..00000000 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/zip/ZipFileParser.kt +++ /dev/null @@ -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? { - if (!file.name.endsWith(".zip", true) || !file.exists()) { - return null - } - - try { - var book: Pair? = 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 -} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/zip/ZipTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/zip/ZipTextParser.kt deleted file mode 100644 index 791f6aac..00000000 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/zip/ZipTextParser.kt +++ /dev/null @@ -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> { - if (!file.name.endsWith(".zip", true) || !file.exists()) { - return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format)) - } - - try { - val lines = mutableListOf() - - 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() ?: "" - ) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt index 78a33bc8..76bd7d74 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt @@ -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 { val textFile = File(textPath) + val lines = mutableListOf() - 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): List { - val books = mutableListOf() - - 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 - } - - if (parsedBook == null) { - books.add( - NullableBook.Null( - file.name, - UIText.StringResource(R.string.error_something_went_wrong_with_file) - ) - ) - continue - } - - if (parsedText is Resource.Error) { - books.add( - 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 - } - - books.add( - NullableBook.NotNull( - book = parsedBook.first, - coverImage = parsedBook.second, - text = parsedText.data - ) + /** + * 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) { + Log.w(GET_BOOK_FROM_FILE, "Parsed book(${file.name}) is null.") + return NullableBook.Null( + file.name, + UIText.StringResource(R.string.error_wrong_file_format) ) } - return books + val parsedText = textParser.parse(file) + if (parsedText is Resource.Error) { + Log.w(GET_BOOK_FROM_FILE, "Parsed text(${file.name}) has error.") + return NullableBook.Null( + file.name, + parsedText.message + ) + } + + return NullableBook.NotNull( + book = parsedBook.first.copy( + chapters = parsedText.data!!.map { it.chapter }.run { + if (this.size == 1) return@run emptyList() + this + } + ), + coverImage = parsedBook.second, + text = parsedText.data.map { + it.text + }.flatten() + ) } override suspend fun insertHistory(history: List) { diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/Book.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/Book.kt index f6d5ba17..b20438a8 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/model/Book.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/model/Book.kt @@ -19,7 +19,8 @@ data class Book( val scrollIndex: Int, val scrollOffset: Int, val progress: Float, + val chapters: List = emptyList(), val lastOpened: Long?, val category: Category, -) +) \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/Chapter.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/Chapter.kt new file mode 100644 index 00000000..6000471f --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/model/Chapter.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt b/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt index b4d99b1b..fb1d8582 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt @@ -68,7 +68,7 @@ interface BookRepository { suspend fun getFilesFromDevice(query: String = ""): List - suspend fun getBooksFromFiles(files: List): List + suspend fun getBookFromFile(file: File): NullableBook suspend fun insertHistory(history: List) diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetBookFromFile.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetBookFromFile.kt index b98c325e..c420bc6e 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetBookFromFile.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetBookFromFile.kt @@ -10,6 +10,6 @@ class GetBookFromFile @Inject constructor( ) { suspend fun execute(file: File): NullableBook { - return repository.getBooksFromFiles(listOf(file)).first() + return repository.getBookFromFile(file) } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBooks.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBook.kt similarity index 54% rename from app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBooks.kt rename to app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBook.kt index 77815cb3..9bb8fd29 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBooks.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBook.kt @@ -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) { - repository.updateBooks(books) + suspend fun execute(book: Book) { + repository.updateBooks(listOf(book)) } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/core/constants/Constants.kt b/app/src/main/java/ua/acclorite/book_story/presentation/core/constants/Constants.kt index d9897650..05642169 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/core/constants/Constants.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/core/constants/Constants.kt @@ -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, diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoViewModel.kt index 70fa217a..15021161 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoViewModel.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoViewModel.kt @@ -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( diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/data/LibraryViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/data/LibraryViewModel.kt index bc46cb4d..e3652faa 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/data/LibraryViewModel.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/data/LibraryViewModel.kt @@ -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() { @@ -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( diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt index 55d27656..6f079fd7 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt @@ -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() + 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( diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderChapter.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderChapter.kt new file mode 100644 index 00000000..3aa0d7f0 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderChapter.kt @@ -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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderChaptersDrawer.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderChaptersDrawer.kt new file mode 100644 index 00000000..8859f181 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderChaptersDrawer.kt @@ -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)}%" + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt index 07db82c8..d5e1eafe 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt @@ -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,54 +126,16 @@ fun ReaderBottomBar() { modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart ) { - Slider( - value = state.value.book.progress, - enabled = !state.value.lockMenu, - onValueChange = { - if (state.value.listState.layoutInfo.totalItemsCount > 0) { - onEvent(ReaderEvent.OnScroll(it)) - onEvent( - ReaderEvent.OnChangeProgress( - progress = it, - firstVisibleItemIndex = state.value.listState.firstVisibleItemIndex, - firstVisibleItemOffset = 0, - refreshList = { book -> - onLibraryEvent(LibraryEvent.OnUpdateBook(book)) - onHistoryEvent(HistoryEvent.OnUpdateBook(book)) - } - ) - ) - } - }, - colors = SliderDefaults.colors( - inactiveTrackColor = MaterialTheme.colorScheme.secondary.copy(0.15f), - disabledActiveTrackColor = MaterialTheme.colorScheme.primary, - disabledThumbColor = MaterialTheme.colorScheme.primary, - disabledInactiveTrackColor = MaterialTheme.colorScheme.secondary.copy(0.15f), - ) - ) + BottomBarSlider() + if (arrowDirection != Direction.NEUTRAL) { - Row(Modifier.fillMaxWidth()) { - Spacer( - modifier = Modifier.fillMaxWidth(checkpointProgress) - ) - Box( - Modifier - .width(4.dp) - .height(16.dp) - .clip(RoundedCornerShape(0.5.dp)) - .background( - MaterialTheme.colorScheme.onPrimary.copy(0.6f) - ) - ) - } + SliderIndicator(progress = checkpointProgress) } } - CustomAnimatedVisibility( + HorizontalExpandingAnimation( visible = arrowDirection == Direction.END, - enter = expandHorizontally() + fadeIn() + slideInHorizontally { it }, - exit = shrinkHorizontally() + fadeOut() + slideOutHorizontally { it } + startDirection = false ) { CustomIconButton( icon = Icons.AutoMirrored.Default.ArrowForward, @@ -193,4 +154,106 @@ fun ReaderBottomBar() { } } } +} + +/** + * 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, + onValueChange = { + if (state.value.listState.layoutInfo.totalItemsCount > 0) { + onEvent(ReaderEvent.OnScroll(it)) + onEvent( + ReaderEvent.OnChangeProgress( + progress = it, + firstVisibleItemIndex = state.value.listState.firstVisibleItemIndex, + firstVisibleItemOffset = 0, + refreshList = { book -> + onLibraryEvent(LibraryEvent.OnUpdateBook(book)) + onHistoryEvent(HistoryEvent.OnUpdateBook(book)) + } + ) + ) + } + }, + colors = SliderDefaults.colors( + inactiveTrackColor = MaterialTheme.colorScheme.secondary.copy(0.15f), + disabledActiveTrackColor = MaterialTheme.colorScheme.primary, + disabledThumbColor = MaterialTheme.colorScheme.primary, + disabledInactiveTrackColor = MaterialTheme.colorScheme.secondary.copy(0.15f), + ) + ) +} + +/** + * Slider Indicator. + * Shows an indicator at desired progress. + */ +@Composable +private fun SliderIndicator(progress: Float) { + Row(Modifier.fillMaxWidth()) { + Spacer( + modifier = Modifier.fillMaxWidth(progress) + ) + Box( + Modifier + .width(4.dp) + .height(16.dp) + .clip(RoundedCornerShape(0.5.dp)) + .background( + MaterialTheme.colorScheme.onPrimary.copy(0.6f) + ) + ) + } +} + +/** + * 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 = visible, + enter = enterAnimation, + exit = exitAnimation + ) { + content() + } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderTopBar.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderTopBar.kt index 7e844276..ec83f114 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderTopBar.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderTopBar.kt @@ -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,88 +51,121 @@ fun ReaderTopBar() { val context = LocalContext.current as ComponentActivity val onNavigate = LocalOnNavigate.current - TopAppBar( - navigationIcon = { - CustomIconButton( - icon = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = R.string.go_back_content_desc, - disableOnClick = true - ) { - onEvent( - ReaderEvent.OnGoBack( - context = context, - refreshList = { - onLibraryEvent(LibraryEvent.OnUpdateBook(it)) - onHistoryEvent(HistoryEvent.OnUpdateBook(it)) - }, - navigate = { - onNavigate { - navigateBack() - } - } - ) - ) - } - }, - title = { - Column(verticalArrangement = Arrangement.Center) { - Text( - state.value.book.title, - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 20.sp, - lineHeight = 20.sp, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - modifier = Modifier - .noRippleClickable( - enabled = !state.value.lockMenu, - onClick = { - onEvent( - ReaderEvent.OnGoBack( - context = context, - refreshList = { - onLibraryEvent(LibraryEvent.OnUpdateBook(it)) - onHistoryEvent(HistoryEvent.OnUpdateBook(it)) - }, - navigate = { - onNavigate { - navigate( - Screen.BookInfo(state.value.book.id), - useBackAnimation = true, - saveInBackStack = false - ) - } - } - ) - ) - } - ) - .basicMarquee( - iterations = Int.MAX_VALUE - ) - ) - Text( - state.value.book.author.asString(), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - }, - actions = { - CustomIconButton( - icon = Icons.Default.Settings, - contentDescription = R.string.open_reader_settings_content_desc, - disableOnClick = false, - enabled = !state.value.lockMenu - ) { - onEvent(ReaderEvent.OnShowHideSettingsBottomSheet) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Colors.readerSystemBarsColor - ) + val animatedChapterProgress = animateFloatAsState( + targetValue = state.value.currentChapterProgress ) + + Column( + Modifier + .fillMaxWidth() + .background(Colors.readerSystemBarsColor) + ) { + TopAppBar( + navigationIcon = { + CustomIconButton( + icon = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = R.string.go_back_content_desc, + disableOnClick = true + ) { + onEvent( + ReaderEvent.OnGoBack( + context = context, + refreshList = { + onLibraryEvent(LibraryEvent.OnUpdateBook(it)) + onHistoryEvent(HistoryEvent.OnUpdateBook(it)) + }, + navigate = { + onNavigate { + navigateBack() + } + } + ) + ) + } + }, + title = { + Column(verticalArrangement = Arrangement.Center) { + Text( + state.value.book.title, + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 20.sp, + lineHeight = 20.sp, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + modifier = Modifier + .noRippleClickable( + enabled = !state.value.lockMenu, + onClick = { + onEvent( + ReaderEvent.OnGoBack( + context = context, + refreshList = { + onLibraryEvent(LibraryEvent.OnUpdateBook(it)) + onHistoryEvent(HistoryEvent.OnUpdateBook(it)) + }, + navigate = { + onNavigate { + navigate( + Screen.BookInfo(state.value.book.id), + useBackAnimation = true, + saveInBackStack = false + ) + } + } + ) + ) + } + ) + .basicMarquee( + iterations = Int.MAX_VALUE + ) + ) + Text( + state.value.currentChapter?.title + ?: context.getString(R.string.no_chapters), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + 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 && + !state.value.showChaptersDrawer && + !state.value.showSettingsBottomSheet + ) { + onEvent(ReaderEvent.OnShowHideSettingsBottomSheet(true)) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ) + ) + + if (state.value.currentChapter != null) { + LinearProgressIndicator( + progress = { animatedChapterProgress.value }, + modifier = Modifier.fillMaxWidth() + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt index d476a5ba..4029238e 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt @@ -79,7 +79,7 @@ fun ReaderSettingsBottomSheet() { .fillMaxHeight(animatedHeight), dragHandle = {}, onDismissRequest = { - onEvent(ReaderEvent.OnShowHideSettingsBottomSheet) + onEvent(ReaderEvent.OnShowHideSettingsBottomSheet(false)) } ) { ReaderSettingsBottomSheetTabRow(pagerState = pagerState) diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt index a9b5c924..766d5533 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt @@ -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, diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt index 05d5e006..063f0d33 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt @@ -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 = 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, diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt index f59e30ed..1b41bda4 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt @@ -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,70 +74,35 @@ 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 - ) - loaded = true - break - } catch (e: Exception) { - Log.w( - "READER", - "Couldn't scroll to desired index and offset" - ) - delay(100) - } - } + _state.value.listState.requestScrollToItem( + _state.value.book.scrollIndex, + _state.value.book.scrollOffset + ) - if (!loaded) { - event.onTextIsEmpty() - } - } - - delay(100) - _state.update { - it.copy( - loading = false - ) - } - - return@collectLatest + delay(100) + _state.update { + it.copy( + loading = false + ) } + + return@collectLatest } } } @@ -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 - ) - } + _state.update { + it.copy( + 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 ( diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 9f26af47..6d62bea3 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -352,6 +352,8 @@ Завантаження… + Немає розділів + Розділи Версія застосунка @@ -596,5 +598,6 @@ Фільтр Чекпоінт назад Чекпоінт вперед + Розділи \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index efcc7e57..3f4279ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -355,6 +355,8 @@ Loading… + No chapters + Chapters App version @@ -601,5 +603,6 @@ Filter Checkpoint back Checkpoint forward + Chapters \ No newline at end of file