🚀 Markdown support in Reader

* Added Markdown support: Bold, Italic, Links and Section separator (hr)
* Added new font variations (Bold & Bold Italic)
* Improve text update (moved to repository & logged)

Resolves: #93
This commit is contained in:
Acclorite 2024-10-01 15:10:44 +03:00
parent 2e041f0cbb
commit b8721b6ad7
48 changed files with 503 additions and 276 deletions

View file

@ -168,4 +168,8 @@ dependencies {
// Gson // Gson
implementation("com.google.code.gson:gson:2.11.0") implementation("com.google.code.gson:gson:2.11.0")
// Markdown
implementation("org.commonmark:commonmark:0.23.0")
implementation("org.commonmark:commonmark-ext-autolink:0.23.0")
} }

View file

@ -8,6 +8,14 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.commonmark.ext.autolink.AutolinkExtension
import org.commonmark.node.BlockQuote
import org.commonmark.node.FencedCodeBlock
import org.commonmark.node.Heading
import org.commonmark.node.HtmlBlock
import org.commonmark.node.IndentedCodeBlock
import org.commonmark.node.ThematicBreak
import org.commonmark.parser.Parser
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import ua.acclorite.book_story.data.local.room.BookDao import ua.acclorite.book_story.data.local.room.BookDao
@ -33,6 +41,26 @@ object AppModule {
.create(GithubAPI::class.java) .create(GithubAPI::class.java)
} }
@Provides
@Singleton
fun provideCommonmarkParser(): Parser {
return Parser
.builder()
.extensions(listOf(AutolinkExtension.create()))
.enabledBlockTypes(
setOf(
Heading::class.java,
HtmlBlock::class.java,
ThematicBreak::class.java,
BlockQuote::class.java,
FencedCodeBlock::class.java,
IndentedCodeBlock::class.java,
ThematicBreak::class.java
)
)
.build()
}
@Provides @Provides
@Singleton @Singleton
fun provideBookDao(app: Application): BookDao { fun provideBookDao(app: Application): BookDao {

View file

@ -2,41 +2,56 @@ package ua.acclorite.book_story.data.parser
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import ua.acclorite.book_story.presentation.core.util.clearMarkdown
import javax.inject.Inject import javax.inject.Inject
class DocumentParser @Inject constructor() { class DocumentParser @Inject constructor() {
/** /**
* Parses document to get it's text. * Parses document to get it's text.
* Fixes issues such as manual line breaking in <p>.
* Applies Markdown to the text: Bold(**), Italic(_), Section separator(---), and Links(a > href).
* *
* @return Parsed text line by line. * @return Parsed text line by line with Markdown(all lines are not blank).
*/ */
suspend fun Document.parseDocument(): List<String> { suspend fun Document.parseDocument(): List<String> {
val lines = mutableListOf<String>() val lines = mutableListOf<String>()
yield() yield()
body() body().apply {
.select("p") // Remove manual line breaks from all <p>
.apply { select("p").forEach { element ->
forEach { element ->
yield() yield()
element.html(element.html().replace(Regex("\\n+"), " "))
val cleanedText = element.html().replace(Regex("\\n+"), " ") element.append("\n")
element.html(cleanedText)
} }
append("\n") // Markdown
select("hr").append("\n---\n")
select("b").append("**").prepend("**")
select("h1").append("**").prepend("**")
select("h2").append("**").prepend("**")
select("h3").append("**").prepend("**")
select("strong").append("**").prepend("**")
select("em").append("_").prepend("_")
select("a").forEach { element ->
val link = element.attr("href")
if (!link.startsWith("http") || element.wholeText().isBlank()) return@forEach
element.prepend("[")
element.append("](${element.attr("href")})")
} }
}.wholeText().lines().forEach { line ->
yield() yield()
body() val formattedLine = line.replace(
.wholeText() Regex("""\*\*\s*(.*?)\s*\*\*"""), "**$1**"
.lines() ).replace(
.forEach { line -> Regex("""_\s*(.*?)\s*_"""), "_$1_"
yield() ).trim()
if (line.isNotBlank()) {
lines.add(line.trim()) if (formattedLine.clearMarkdown().isNotBlank()) {
lines.add(formattedLine)
} }
} }

View file

@ -0,0 +1,92 @@
package ua.acclorite.book_story.data.parser
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import org.commonmark.node.Emphasis
import org.commonmark.node.Heading
import org.commonmark.node.Link
import org.commonmark.node.Node
import org.commonmark.node.StrongEmphasis
import org.commonmark.node.Text
import org.commonmark.parser.Parser
import ua.acclorite.book_story.presentation.core.util.clearMarkdown
import javax.inject.Inject
class MarkdownParser @Inject constructor(
private val commonmarkParser: Parser
) {
/**
* Parses markdown text to [androidx.compose.ui.text.AnnotatedString].
*
* @return Parsed annotated string.
*/
fun parse(markdown: String): AnnotatedString {
return try {
val annotatedString = buildAnnotatedString {
parseNode(commonmarkParser.parse(markdown))
}.ifBlank { buildAnnotatedString { append(markdown) } }
.trim() as AnnotatedString
annotatedString
} catch (e: Exception) {
e.printStackTrace()
buildAnnotatedString { append(markdown) }
}
}
/**
* Parses [Node].
* Appends text and applies styles to the target [AnnotatedString.Builder].
*/
private fun AnnotatedString.Builder.parseNode(node: Node) {
when (node) {
is Heading, is StrongEmphasis -> {
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
parseChildren(node)
}
}
is Emphasis -> {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
parseChildren(node)
}
}
is Link -> {
withLink(
LinkAnnotation.Url(
node.destination,
styles = TextLinkStyles(style = SpanStyle(textDecoration = TextDecoration.Underline))
)
) {
parseChildren(node)
}
}
is Text -> {
append(node.literal.clearMarkdown())
parseChildren(node)
}
else -> {
parseChildren(node)
}
}
}
private fun AnnotatedString.Builder.parseChildren(node: Node) {
var child = node.firstChild
while (child != null) {
parseNode(child)
child = child.next
}
}
}

View file

@ -208,7 +208,7 @@ class EpubTextParser @Inject constructor(
val chapter = documentParser.run { val chapter = documentParser.run {
Jsoup.parse(content).parseDocument().dropWhile { Jsoup.parse(content).parseDocument().dropWhile {
it == chapterTitle // Remove chapter title if present it.clearMarkdown().lowercase() == chapterTitle.lowercase()
} }
} }
if (chapter.isEmpty()) { if (chapter.isEmpty()) {

View file

@ -7,12 +7,14 @@ import android.net.Uri
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.compose.ui.text.AnnotatedString
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.local.data_store.DataStore import ua.acclorite.book_story.data.local.data_store.DataStore
import ua.acclorite.book_story.data.local.dto.FavoriteDirectoryEntity import ua.acclorite.book_story.data.local.dto.FavoriteDirectoryEntity
@ -22,13 +24,14 @@ import ua.acclorite.book_story.data.mapper.book.BookMapper
import ua.acclorite.book_story.data.mapper.color_preset.ColorPresetMapper import ua.acclorite.book_story.data.mapper.color_preset.ColorPresetMapper
import ua.acclorite.book_story.data.mapper.history.HistoryMapper import ua.acclorite.book_story.data.mapper.history.HistoryMapper
import ua.acclorite.book_story.data.parser.FileParser import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.data.parser.MarkdownParser
import ua.acclorite.book_story.data.parser.TextParser import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.data.remote.GithubAPI import ua.acclorite.book_story.data.remote.GithubAPI
import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.BookWithText import ua.acclorite.book_story.domain.model.BookWithText
import ua.acclorite.book_story.domain.model.BookWithTextAndCover import ua.acclorite.book_story.domain.model.BookWithTextAndCover
import ua.acclorite.book_story.domain.model.ChapterWithText 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.History import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook import ua.acclorite.book_story.domain.model.NullableBook
@ -60,6 +63,7 @@ private const val RESET_COVER = "RESET COVER, REPOSITORY"
private const val GET_ALL_SETTINGS = "GET ALL SETTINGS, REPOSITORY" private const val GET_ALL_SETTINGS = "GET ALL SETTINGS, REPOSITORY"
private const val GET_FILES_FROM_DEVICE = "GET FILES FROM DEVICE, REPOSITORY" private const val GET_FILES_FROM_DEVICE = "GET FILES FROM DEVICE, REPOSITORY"
private const val CHECK_FOR_UPDATES = "CHECK FOR UPDATES, REPOSITORY" private const val CHECK_FOR_UPDATES = "CHECK FOR UPDATES, REPOSITORY"
private const val CHECK_FOR_TEXT_UPDATE = "CHECK FOR TEXT UPDATE, REPOSITORY"
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@Singleton @Singleton
@ -77,6 +81,7 @@ class BookRepositoryImpl @Inject constructor(
private val fileParser: FileParser, private val fileParser: FileParser,
private val textParser: TextParser, private val textParser: TextParser,
private val markdownParser: MarkdownParser
) : BookRepository { ) : BookRepository {
/** /**
@ -123,27 +128,115 @@ class BookRepositoryImpl @Inject constructor(
* Loads text from given path. Should be .txt. * Loads text from given path. Should be .txt.
* Used to get text from book and load Reader. * Used to get text from book and load Reader.
*/ */
override suspend fun getBookText(textPath: String): List<String> { override suspend fun getBookText(textPath: String): List<AnnotatedString> {
val textFile = File(textPath) val textFile = File(textPath)
val lines = mutableListOf<String>() val markdownLines = mutableListOf<AnnotatedString>()
if (textPath.isBlank() || !textFile.exists() || textFile.extension != "txt") { if (textPath.isBlank() || !textFile.exists() || textFile.extension != "txt") {
Log.w(GET_TEXT, "Failed to load file: $textPath") Log.w(GET_TEXT, "Failed to load file: $textPath")
return emptyList() return emptyList()
} }
try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
BufferedReader(FileReader(textFile)).forEachLine { line -> BufferedReader(FileReader(textFile)).forEachLine { line ->
if (line.isNotBlank()) { if (line.isNotBlank()) {
lines.add( markdownLines.add(
line.trim() markdownParser.parse(line.trim())
) )
} }
} }
} }
} catch (e: Exception) {
e.printStackTrace()
Log.e(GET_TEXT, "Could not get text with markdown.")
return emptyList()
}
Log.i(GET_TEXT, "Successfully loaded text.") Log.i(GET_TEXT, "Successfully loaded text with markdown.")
return lines return markdownLines
}
/**
* Checks whether the text of the book([bookId]) is up-to-date and did not change.
*
* @return If [Resource.Success] and returns null, then the book is up-to-date.
*/
override suspend fun checkTextForUpdate(bookId: Int): Resource<Pair<List<String>, List<Chapter>>?> {
yield()
try {
val book = database.findBookById(bookId)
Log.i(CHECK_FOR_TEXT_UPDATE, "Checking [${book.title}] for text update.")
yield()
val text = withContext(Dispatchers.IO) {
val lines = mutableListOf<String>()
BufferedReader(FileReader(book.textPath)).forEachLine { line ->
if (line.isNotBlank()) {
lines.add(line.trim())
}
}
lines.toList()
}
val chapters = book.chapters
Log.i(CHECK_FOR_TEXT_UPDATE, "Got current text and chapters.")
yield()
val bookFile = File(book.filePath).apply {
if (!exists()) {
Log.e(CHECK_FOR_TEXT_UPDATE, "Couldn't get book's file: does not exist.")
return Resource.Error(
message = UIText.StringResource(
R.string.file_not_found,
name.takeLast(50)
)
)
}
}
yield()
val (updatedText, updatedChapters) = textParser.parse(bookFile).run {
if (this is Resource.Error) {
Log.e(CHECK_FOR_TEXT_UPDATE, "Couldn't get updated book's text.")
return Resource.Error(
message = UIText.StringResource(
R.string.error_file_empty
)
)
}
data!!.map { it.text }.flatten() to data.map { it.chapter }.run {
if (size < 2) return@run emptyList()
return@run this
}
}
Log.i(CHECK_FOR_TEXT_UPDATE, "Successfully got new text and chapters.")
yield()
return Resource.Success(
if (updatedText == text && updatedChapters == chapters) {
Log.i(CHECK_FOR_TEXT_UPDATE, "Text is up-to-date(${book.title}).")
null
} else {
Log.i(CHECK_FOR_TEXT_UPDATE, "Found difference in ${book.title}.")
updatedText to updatedChapters
}
)
} catch (e: Exception) {
e.printStackTrace()
Log.e(CHECK_FOR_TEXT_UPDATE, "Check failed with the error: ${e.message}")
return Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message ?: ""
)
)
}
} }
/** /**
@ -700,16 +793,6 @@ class BookRepositoryImpl @Inject constructor(
) )
} }
/**
* Parse text from given [file].
* May give [Resource.Error] if something went wrong.
*
* @param file File to parse. Should be one of supported file formats.
*/
override suspend fun parseText(file: File): Resource<List<ChapterWithText>> {
return textParser.parse(file)
}
/** /**
* Insert history in database. * Insert history in database.
*/ */

View file

@ -1,11 +1,12 @@
package ua.acclorite.book_story.domain.repository package ua.acclorite.book_story.domain.repository
import androidx.compose.ui.text.AnnotatedString
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.BookWithText import ua.acclorite.book_story.domain.model.BookWithText
import ua.acclorite.book_story.domain.model.BookWithTextAndCover import ua.acclorite.book_story.domain.model.BookWithTextAndCover
import ua.acclorite.book_story.domain.model.ChapterWithText 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.History import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook import ua.acclorite.book_story.domain.model.NullableBook
@ -28,7 +29,9 @@ interface BookRepository {
suspend fun getBookText( suspend fun getBookText(
textPath: String textPath: String
): List<String> ): List<AnnotatedString>
suspend fun checkTextForUpdate(bookId: Int): Resource<Pair<List<String>, List<Chapter>>?>
suspend fun insertBook( suspend fun insertBook(
bookWithTextAndCover: BookWithTextAndCover bookWithTextAndCover: BookWithTextAndCover
@ -79,8 +82,6 @@ interface BookRepository {
suspend fun getBookFromFile( suspend fun getBookFromFile(
file: File file: File
): NullableBook ): NullableBook
suspend fun parseText(file: File): Resource<List<ChapterWithText>>
/* - - - - - - - - - - - - - - - - - - - - - - */ /* - - - - - - - - - - - - - - - - - - - - - - */

View file

@ -0,0 +1,13 @@
package ua.acclorite.book_story.domain.use_case
import ua.acclorite.book_story.domain.model.Chapter
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.domain.util.Resource
import javax.inject.Inject
class CheckTextForUpdate @Inject constructor(private val repository: BookRepository) {
suspend fun execute(bookId: Int): Resource<Pair<List<String>, List<Chapter>>?> {
return repository.checkTextForUpdate(bookId)
}
}

View file

@ -1,11 +1,12 @@
package ua.acclorite.book_story.domain.use_case package ua.acclorite.book_story.domain.use_case
import androidx.compose.ui.text.AnnotatedString
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 GetText @Inject constructor(private val repository: BookRepository) { class GetText @Inject constructor(private val repository: BookRepository) {
suspend fun execute(textPath: String): List<String> { suspend fun execute(textPath: String): List<AnnotatedString> {
return repository.getBookText(textPath = textPath) return repository.getBookText(textPath = textPath)
} }
} }

View file

@ -1,14 +0,0 @@
package ua.acclorite.book_story.domain.use_case
import ua.acclorite.book_story.domain.model.ChapterWithText
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.domain.util.Resource
import java.io.File
import javax.inject.Inject
class ParseText @Inject constructor(private val repository: BookRepository) {
suspend fun execute(file: File): Resource<List<ChapterWithText>> {
return repository.parseText(file)
}
}

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import my.nanihadesuka.compose.ScrollbarSelectionMode import my.nanihadesuka.compose.ScrollbarSelectionMode
import my.nanihadesuka.compose.ScrollbarSettings import my.nanihadesuka.compose.ScrollbarSettings
@ -457,7 +458,9 @@ private fun provideFonts() = listOf(
UIText.StringValue("Raleway"), UIText.StringValue("Raleway"),
FontFamily( FontFamily(
Font(R.font.raleway_regular), Font(R.font.raleway_regular),
Font(R.font.raleway_regular_italic, style = FontStyle.Italic) Font(R.font.raleway_regular_italic, style = FontStyle.Italic),
Font(R.font.raleway_medium, weight = FontWeight.Medium),
Font(R.font.raleway_medium_italic, weight = FontWeight.Medium, style = FontStyle.Italic)
) )
), ),
FontWithName( FontWithName(
@ -465,7 +468,13 @@ private fun provideFonts() = listOf(
UIText.StringValue("Open Sans"), UIText.StringValue("Open Sans"),
FontFamily( FontFamily(
Font(R.font.opensans_regular), Font(R.font.opensans_regular),
Font(R.font.opensans_regular_italic, style = FontStyle.Italic) Font(R.font.opensans_regular_italic, style = FontStyle.Italic),
Font(R.font.opensans_medium, weight = FontWeight.Medium),
Font(
R.font.opensans_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
)
) )
), ),
FontWithName( FontWithName(
@ -473,7 +482,9 @@ private fun provideFonts() = listOf(
UIText.StringValue("Mulish"), UIText.StringValue("Mulish"),
FontFamily( FontFamily(
Font(R.font.mulish_regular), Font(R.font.mulish_regular),
Font(R.font.mulish_regular_italic, style = FontStyle.Italic) Font(R.font.mulish_regular_italic, style = FontStyle.Italic),
Font(R.font.mulish_medium, weight = FontWeight.Medium),
Font(R.font.mulish_medium_italic, weight = FontWeight.Medium, style = FontStyle.Italic)
) )
), ),
FontWithName( FontWithName(
@ -481,7 +492,9 @@ private fun provideFonts() = listOf(
UIText.StringValue("Arimo"), UIText.StringValue("Arimo"),
FontFamily( FontFamily(
Font(R.font.arimo_regular), Font(R.font.arimo_regular),
Font(R.font.arimo_regular_italic, style = FontStyle.Italic) Font(R.font.arimo_regular_italic, style = FontStyle.Italic),
Font(R.font.arimo_medium, weight = FontWeight.Medium),
Font(R.font.arimo_medium_italic, weight = FontWeight.Medium, style = FontStyle.Italic)
) )
), ),
FontWithName( FontWithName(
@ -489,7 +502,13 @@ private fun provideFonts() = listOf(
UIText.StringValue("Garamond"), UIText.StringValue("Garamond"),
FontFamily( FontFamily(
Font(R.font.garamond_regular), Font(R.font.garamond_regular),
Font(R.font.garamond_regular_italic, style = FontStyle.Italic) Font(R.font.garamond_regular_italic, style = FontStyle.Italic),
Font(R.font.garamond_medium, weight = FontWeight.Medium),
Font(
R.font.garamond_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
)
) )
), ),
FontWithName( FontWithName(
@ -497,7 +516,13 @@ private fun provideFonts() = listOf(
UIText.StringValue("Roboto Serif"), UIText.StringValue("Roboto Serif"),
FontFamily( FontFamily(
Font(R.font.robotoserif_regular), Font(R.font.robotoserif_regular),
Font(R.font.robotoserif_regular_italic, style = FontStyle.Italic) Font(R.font.robotoserif_regular_italic, style = FontStyle.Italic),
Font(R.font.robotoserif_medium, weight = FontWeight.Medium),
Font(
R.font.robotoserif_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
)
) )
), ),
FontWithName( FontWithName(
@ -505,7 +530,13 @@ private fun provideFonts() = listOf(
UIText.StringValue("Noto Serif"), UIText.StringValue("Noto Serif"),
FontFamily( FontFamily(
Font(R.font.notoserif_regular), Font(R.font.notoserif_regular),
Font(R.font.notoserif_regular_italic, style = FontStyle.Italic) Font(R.font.notoserif_regular_italic, style = FontStyle.Italic),
Font(R.font.notoserif_medium, weight = FontWeight.Medium),
Font(
R.font.notoserif_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
)
) )
), ),
FontWithName( FontWithName(
@ -513,7 +544,13 @@ private fun provideFonts() = listOf(
UIText.StringValue("Noto Sans"), UIText.StringValue("Noto Sans"),
FontFamily( FontFamily(
Font(R.font.notosans_regular), Font(R.font.notosans_regular),
Font(R.font.notosans_regular_italic, style = FontStyle.Italic) Font(R.font.notosans_regular_italic, style = FontStyle.Italic),
Font(R.font.notosans_medium, weight = FontWeight.Medium),
Font(
R.font.notosans_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
)
) )
), ),
FontWithName( FontWithName(
@ -521,7 +558,9 @@ private fun provideFonts() = listOf(
UIText.StringValue("Roboto"), UIText.StringValue("Roboto"),
FontFamily( FontFamily(
Font(R.font.roboto_regular), Font(R.font.roboto_regular),
Font(R.font.roboto_regular_italic, style = FontStyle.Italic) Font(R.font.roboto_regular_italic, style = FontStyle.Italic),
Font(R.font.roboto_medium, weight = FontWeight.Medium),
Font(R.font.roboto_medium_italic, weight = FontWeight.Medium, style = FontStyle.Italic)
) )
), ),
FontWithName( FontWithName(
@ -529,7 +568,9 @@ private fun provideFonts() = listOf(
UIText.StringValue("Jost"), UIText.StringValue("Jost"),
FontFamily( FontFamily(
Font(R.font.jost_regular), Font(R.font.jost_regular),
Font(R.font.jost_regular_italic, style = FontStyle.Italic) Font(R.font.jost_regular_italic, style = FontStyle.Italic),
Font(R.font.jost_medium, weight = FontWeight.Medium),
Font(R.font.jost_medium_italic, weight = FontWeight.Medium, style = FontStyle.Italic)
) )
), ),
FontWithName( FontWithName(
@ -537,7 +578,13 @@ private fun provideFonts() = listOf(
UIText.StringValue("Merriweather"), UIText.StringValue("Merriweather"),
FontFamily( FontFamily(
Font(R.font.merriweather_regular), Font(R.font.merriweather_regular),
Font(R.font.merriweather_regular_italic, style = FontStyle.Italic) Font(R.font.merriweather_regular_italic, style = FontStyle.Italic),
Font(R.font.merriweather_medium, weight = FontWeight.Medium),
Font(
R.font.merriweather_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
)
) )
), ),
FontWithName( FontWithName(
@ -545,7 +592,13 @@ private fun provideFonts() = listOf(
UIText.StringValue("Montserrat"), UIText.StringValue("Montserrat"),
FontFamily( FontFamily(
Font(R.font.montserrat_regular), Font(R.font.montserrat_regular),
Font(R.font.montserrat_regular_italic, style = FontStyle.Italic) Font(R.font.montserrat_regular_italic, style = FontStyle.Italic),
Font(R.font.montserrat_medium, weight = FontWeight.Medium),
Font(
R.font.montserrat_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
)
) )
), ),
FontWithName( FontWithName(
@ -553,14 +606,17 @@ private fun provideFonts() = listOf(
UIText.StringValue("Nunito"), UIText.StringValue("Nunito"),
FontFamily( FontFamily(
Font(R.font.nunito_regular), Font(R.font.nunito_regular),
Font(R.font.nunito_regular_italic, style = FontStyle.Italic) Font(R.font.nunito_regular_italic, style = FontStyle.Italic),
Font(R.font.nunito_medium, weight = FontWeight.Medium),
Font(R.font.nunito_medium_italic, weight = FontWeight.Medium, style = FontStyle.Italic)
) )
), ),
FontWithName( FontWithName(
"roboto_slab", "roboto_slab",
UIText.StringValue("Roboto Slab"), UIText.StringValue("Roboto Slab"),
FontFamily( FontFamily(
Font(R.font.robotoslab_regular) Font(R.font.robotoslab_regular),
Font(R.font.robotoslab_medium, weight = FontWeight.Medium),
) )
), ),
FontWithName( FontWithName(
@ -568,7 +624,9 @@ private fun provideFonts() = listOf(
UIText.StringValue("Lora"), UIText.StringValue("Lora"),
FontFamily( FontFamily(
Font(R.font.lora_regular), Font(R.font.lora_regular),
Font(R.font.lora_regular_italic, style = FontStyle.Italic) Font(R.font.lora_regular_italic, style = FontStyle.Italic),
Font(R.font.lora_medium, weight = FontWeight.Medium),
Font(R.font.lora_medium_italic, weight = FontWeight.Medium, style = FontStyle.Italic)
) )
) )
) )

View file

@ -31,6 +31,7 @@ fun Intent.launchActivity(
try { try {
activity.baseContext.startActivity(intent) activity.baseContext.startActivity(intent)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace()
error() error()
return return
} }
@ -64,5 +65,5 @@ fun Modifier.noRippleClickable(
} }
fun String.clearMarkdown(): String { fun String.clearMarkdown(): String {
return replace(Regex("_|\\*\\*"), "").trim() return replace(Regex("_|\\*\\*"), "")
} }

View file

@ -26,11 +26,10 @@ import ua.acclorite.book_story.domain.model.BookWithText
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.use_case.CanResetCover import ua.acclorite.book_story.domain.use_case.CanResetCover
import ua.acclorite.book_story.domain.use_case.CheckTextForUpdate
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.GetBookById import ua.acclorite.book_story.domain.use_case.GetBookById
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.ParseText
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.UpdateBook
import ua.acclorite.book_story.domain.use_case.UpdateBookWithText import ua.acclorite.book_story.domain.use_case.UpdateBookWithText
@ -40,7 +39,6 @@ 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.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.io.File
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -54,10 +52,9 @@ class BookInfoViewModel @Inject constructor(
private val insertHistory: InsertHistory, private val insertHistory: InsertHistory,
private val deleteBooks: DeleteBooks, private val deleteBooks: DeleteBooks,
private val getBookById: GetBookById, private val getBookById: GetBookById,
private val getText: GetText,
private val canResetCover: CanResetCover, private val canResetCover: CanResetCover,
private val resetCoverImage: ResetCoverImage, private val resetCoverImage: ResetCoverImage,
private val parseText: ParseText private val checkTextForUpdate: CheckTextForUpdate,
) : BaseViewModel<BookInfoState, BookInfoEvent>() { ) : BaseViewModel<BookInfoState, BookInfoEvent>() {
private val _state = MutableStateFlow(BookInfoState()) private val _state = MutableStateFlow(BookInfoState())
@ -468,83 +465,10 @@ class BookInfoViewModel @Inject constructor(
yield() yield()
val currentBook = _state.value.book.apply { val result = checkTextForUpdate.execute(bookId = _state.value.book.id)
if (!File(filePath).exists()) { when (result) {
onEvent( is Resource.Success -> {
BookInfoEvent.OnShowSnackbar( if (result.data == null) {
text = event.context.getString(
R.string.file_not_found,
filePath.substringAfterLast("/").takeLast(25)
),
action = event.context.getString(R.string.retry),
onAction = {
onEvent(
BookInfoEvent.OnCheckForUpdate(
snackbarState = event.snackbarState,
context = event.context
)
)
},
durationMillis = 4000L,
snackbarState = event.snackbarState
)
)
delay(500)
_state.update {
it.copy(
checkingForUpdate = false
)
}
return@launch
}
}
val updatedBook = parseText.execute(File(currentBook.filePath)).apply {
if (this is Resource.Error || data == null) {
onEvent(
BookInfoEvent.OnShowSnackbar(
text = message?.asString(event.context)
?: event.context.getString(
R.string.error_something_went_wrong_with_file
),
action = event.context.getString(R.string.retry),
onAction = {
onEvent(
BookInfoEvent.OnCheckForUpdate(
snackbarState = event.snackbarState,
context = event.context
)
)
},
durationMillis = 4000L,
snackbarState = event.snackbarState
)
)
delay(500)
_state.update {
it.copy(
checkingForUpdate = false
)
}
return@launch
}
}.data!!
yield()
val updatedText = updatedBook.map { it.text }.flatten()
val text = getText.execute(currentBook.textPath)
val updatedChapters = updatedBook.map { it.chapter }.run {
if (size < 2) return@run emptyList()
return@run this
}
val chapters = currentBook.chapters
yield()
if (updatedText == text && updatedChapters == chapters) {
onEvent( onEvent(
BookInfoEvent.OnShowSnackbar( BookInfoEvent.OnShowSnackbar(
event.context.getString(R.string.nothing_changed), event.context.getString(R.string.nothing_changed),
@ -560,14 +484,11 @@ class BookInfoViewModel @Inject constructor(
) )
} }
return@launch return@launch
} } else {
yield()
onEvent( onEvent(
BookInfoEvent.OnShowConfirmUpdateDialog( BookInfoEvent.OnShowConfirmUpdateDialog(
updatedText = updatedText, updatedText = result.data.first,
updatedChapters = updatedChapters updatedChapters = result.data.second
) )
) )
@ -576,6 +497,37 @@ class BookInfoViewModel @Inject constructor(
checkingForUpdate = false checkingForUpdate = false
) )
} }
return@launch
}
}
is Resource.Error -> {
onEvent(
BookInfoEvent.OnShowSnackbar(
text = result.message?.asString(event.context) ?: "",
action = event.context.getString(R.string.retry),
onAction = {
onEvent(
BookInfoEvent.OnCheckForUpdate(
snackbarState = event.snackbarState,
context = event.context
)
)
},
durationMillis = 4000L,
snackbarState = event.snackbarState
)
)
delay(500)
_state.update {
it.copy(
checkingForUpdate = false
)
}
return@launch
}
}
} }
} }

View file

@ -494,7 +494,6 @@ private fun ReaderScreen(lazyListState: LazyListState) {
ReaderTextParagraph( ReaderTextParagraph(
line = line, line = line,
context = context,
fontFamily = fontFamily, fontFamily = fontFamily,
fontColor = fontColor.value, fontColor = fontColor.value,
lineHeight = lineHeight, lineHeight = lineHeight,

View file

@ -1,17 +1,21 @@
package ua.acclorite.book_story.presentation.screens.reader.components package ua.acclorite.book_story.presentation.screens.reader.components
import android.content.Context
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
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.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.LineBreak
@ -19,6 +23,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.FontWithName import ua.acclorite.book_story.domain.model.FontWithName
import ua.acclorite.book_story.presentation.core.components.LocalReaderViewModel import ua.acclorite.book_story.presentation.core.components.LocalReaderViewModel
@ -31,7 +36,6 @@ import ua.acclorite.book_story.presentation.screens.settings.nested.reader.data.
* Reader Text Paragraph item. * Reader Text Paragraph item.
* *
* @param line Current line. * @param line Current line.
* @param context Context.
* @param fontFamily Line's font family. * @param fontFamily Line's font family.
* @param fontColor Line's font color. * @param fontColor Line's font color.
* @param lineHeight Line's line height. * @param lineHeight Line's line height.
@ -47,8 +51,7 @@ import ua.acclorite.book_story.presentation.screens.settings.nested.reader.data.
*/ */
@Composable @Composable
fun LazyItemScope.ReaderTextParagraph( fun LazyItemScope.ReaderTextParagraph(
line: String, line: AnnotatedString,
context: Context,
fontFamily: FontWithName, fontFamily: FontWithName,
fontColor: Color, fontColor: Color,
lineHeight: TextUnit, lineHeight: TextUnit,
@ -63,14 +66,13 @@ fun LazyItemScope.ReaderTextParagraph(
toolbarHidden: Boolean toolbarHidden: Boolean
) { ) {
val onEvent = LocalReaderViewModel.current.onEvent val onEvent = LocalReaderViewModel.current.onEvent
val context = LocalContext.current
Column( Column(
modifier = Modifier modifier = Modifier
.animateItem(fadeInSpec = null, fadeOutSpec = null) .animateItem(fadeInSpec = null, fadeOutSpec = null)
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(horizontal = sidePadding),
horizontal = sidePadding
),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = when (textAlignment) { horizontalAlignment = when (textAlignment) {
ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start
@ -78,6 +80,16 @@ fun LazyItemScope.ReaderTextParagraph(
ReaderTextAlignment.END -> Alignment.End ReaderTextAlignment.END -> Alignment.End
} }
) { ) {
when (line.text) {
"---" -> {
HorizontalDivider(
thickness = 3.dp,
modifier = Modifier.clip(CircleShape),
color = fontColor.copy(0.3f)
)
}
else -> {
BasicText( BasicText(
text = line, text = line,
modifier = Modifier.then( modifier = Modifier.then(
@ -89,7 +101,7 @@ fun LazyItemScope.ReaderTextParagraph(
onDoubleClick = { onDoubleClick = {
onEvent( onEvent(
ReaderEvent.OnOpenTranslator( ReaderEvent.OnOpenTranslator(
textToTranslate = line, textToTranslate = line.text,
translateWholeParagraph = true, translateWholeParagraph = true,
context = context as ComponentActivity, context = context as ComponentActivity,
noAppsFound = { noAppsFound = {
@ -131,4 +143,6 @@ fun LazyItemScope.ReaderTextParagraph(
) )
) )
} }
}
}
} }

View file

@ -2,6 +2,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 androidx.compose.ui.text.AnnotatedString
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.model.Chapter
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
@ -10,7 +11,7 @@ import ua.acclorite.book_story.presentation.core.constants.Constants
@Immutable @Immutable
data class ReaderState( data class ReaderState(
val book: Book = Constants.EMPTY_BOOK, val book: Book = Constants.EMPTY_BOOK,
val text: List<String> = emptyList(), val text: List<AnnotatedString> = emptyList(),
val listState: LazyListState = LazyListState(), val listState: LazyListState = LazyListState(),
val currentChapter: Chapter? = null, val currentChapter: Chapter? = null,

View file

@ -26,10 +26,10 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.use_case.CheckTextForUpdate
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.ParseText
import ua.acclorite.book_story.domain.use_case.UpdateBook 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.Resource import ua.acclorite.book_story.domain.util.Resource
@ -37,7 +37,6 @@ 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
import ua.acclorite.book_story.presentation.core.util.BaseViewModel import ua.acclorite.book_story.presentation.core.util.BaseViewModel
import ua.acclorite.book_story.presentation.core.util.launchActivity import ua.acclorite.book_story.presentation.core.util.launchActivity
import java.io.File
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -48,7 +47,7 @@ class ReaderViewModel @Inject constructor(
private val getText: GetText, private val getText: GetText,
private val getLatestHistory: GetLatestHistory, private val getLatestHistory: GetLatestHistory,
private val getBookById: GetBookById, private val getBookById: GetBookById,
private val parseText: ParseText private val checkTextForUpdate: CheckTextForUpdate
) : BaseViewModel<ReaderState, ReaderEvent>() { ) : BaseViewModel<ReaderState, ReaderEvent>() {
private val _state = MutableStateFlow(ReaderState()) private val _state = MutableStateFlow(ReaderState())
@ -421,43 +420,10 @@ class ReaderViewModel @Inject constructor(
yield() yield()
val currentBook = _state.value.book.apply { val result = checkTextForUpdate.execute(bookId = _state.value.book.id)
if (!File(filePath).exists()) { when (result) {
_state.update { is Resource.Success -> {
it.copy( if (result.data == null) {
checkingForUpdate = false,
updateFound = false
)
}
return@launch
}
}
val updatedBook = parseText.execute(File(currentBook.filePath)).apply {
if (this is Resource.Error || data == null) {
_state.update {
it.copy(
checkingForUpdate = false,
updateFound = false
)
}
return@launch
}
}.data!!
yield()
val updatedText = updatedBook.map { it.text }.flatten()
val text = getText.execute(currentBook.textPath)
val updatedChapters = updatedBook.map { it.chapter }.run {
if (size < 2) return@run emptyList()
return@run this
}
val chapters = currentBook.chapters
yield()
if (updatedText == text && updatedChapters == chapters) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
event.noUpdateFound() event.noUpdateFound()
} }
@ -468,8 +434,7 @@ class ReaderViewModel @Inject constructor(
) )
} }
return@launch return@launch
} } else {
_state.update { _state.update {
it.copy( it.copy(
updateFound = true, updateFound = true,
@ -477,6 +442,20 @@ class ReaderViewModel @Inject constructor(
) )
} }
onEvent(ReaderEvent.OnShowHideUpdateDialog(true)) onEvent(ReaderEvent.OnShowHideUpdateDialog(true))
return@launch
}
}
is Resource.Error -> {
_state.update {
it.copy(
checkingForUpdate = false,
updateFound = false
)
}
return@launch
}
}
} }
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.