🚀 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:
parent
06fd97bd88
commit
94c49c20c8
43 changed files with 1191 additions and 615 deletions
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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` (" +
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>>
|
||||||
}
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,27 +86,27 @@ 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()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractCoverImageBitmap(file: File, coverImagePath: String?): Bitmap? {
|
||||||
|
if (coverImagePath.isNullOrBlank()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractCoverImageBitmap(file: File, coverImagePath: String?): Bitmap? {
|
ZipFile(file).use { zip ->
|
||||||
if (coverImagePath.isNullOrBlank()) {
|
zip.entries().asSequence().forEach { entry ->
|
||||||
return null
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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()) {
|
chapters.addAll(this)
|
||||||
lines.add(line.trim())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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() ?: ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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() ?: ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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() ?: ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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() ?: ""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
if (parsedBook == null) {
|
||||||
} else {
|
Log.w(GET_BOOK_FROM_FILE, "Parsed book(${file.name}) is null.")
|
||||||
books.add(
|
return NullableBook.Null(
|
||||||
NullableBook.Null(
|
file.name,
|
||||||
file.name,
|
UIText.StringResource(R.string.error_wrong_file_format)
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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<History>) {
|
override suspend fun insertHistory(history: List<History>) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
|
@ -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>)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)}%"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,54 +126,16 @@ fun ReaderBottomBar() {
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
contentAlignment = Alignment.CenterStart
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
Slider(
|
BottomBarSlider()
|
||||||
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),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (arrowDirection != Direction.NEUTRAL) {
|
if (arrowDirection != Direction.NEUTRAL) {
|
||||||
Row(Modifier.fillMaxWidth()) {
|
SliderIndicator(progress = checkpointProgress)
|
||||||
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)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomAnimatedVisibility(
|
HorizontalExpandingAnimation(
|
||||||
visible = arrowDirection == Direction.END,
|
visible = arrowDirection == Direction.END,
|
||||||
enter = expandHorizontally() + fadeIn() + slideInHorizontally { it },
|
startDirection = false
|
||||||
exit = shrinkHorizontally() + fadeOut() + slideOutHorizontally { it }
|
|
||||||
) {
|
) {
|
||||||
CustomIconButton(
|
CustomIconButton(
|
||||||
icon = Icons.AutoMirrored.Default.ArrowForward,
|
icon = Icons.AutoMirrored.Default.ArrowForward,
|
||||||
|
|
@ -194,3 +155,105 @@ 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,88 +51,121 @@ fun ReaderTopBar() {
|
||||||
val context = LocalContext.current as ComponentActivity
|
val context = LocalContext.current as ComponentActivity
|
||||||
val onNavigate = LocalOnNavigate.current
|
val onNavigate = LocalOnNavigate.current
|
||||||
|
|
||||||
TopAppBar(
|
val animatedChapterProgress = animateFloatAsState(
|
||||||
navigationIcon = {
|
targetValue = state.value.currentChapterProgress
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,70 +74,35 @@ 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) {
|
_state.value.listState.requestScrollToItem(
|
||||||
if (index > 0 || offset > 0) {
|
_state.value.book.scrollIndex,
|
||||||
var loaded = false
|
_state.value.book.scrollOffset
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!loaded) {
|
delay(100)
|
||||||
event.onTextIsEmpty()
|
_state.update {
|
||||||
}
|
it.copy(
|
||||||
}
|
loading = false
|
||||||
|
)
|
||||||
delay(100)
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
loading = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return@collectLatest
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return@collectLatest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 = event.show
|
||||||
showSettingsBottomSheet = !it.showSettingsBottomSheet
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue