diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index ae3f30ae..6d0ee1c2 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d3711c0e..dd1efada 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,10 +1,13 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
id("kotlin-parcelize")
id("com.mikepenz.aboutlibraries.plugin")
+ id("androidx.room")
+ id("org.jetbrains.kotlin.plugin.serialization")
}
android {
@@ -24,6 +27,10 @@ android {
}
}
+ room {
+ schemaDirectory("$projectDir/schemas")
+ }
+
buildTypes {
getByName("debug") {
applicationIdSuffix = ".debug"
@@ -76,12 +83,12 @@ aboutLibraries {
dependencies {
// Default
- implementation("androidx.core:core-ktx:1.13.0")
- implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.1")
implementation("androidx.activity:activity-compose:1.9.0")
// Compose BOM libraries
- implementation(platform("androidx.compose:compose-bom:2024.04.01"))
+ implementation(platform("androidx.compose:compose-bom:2024.05.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.animation:animation")
implementation("androidx.compose.animation:animation-android")
@@ -90,18 +97,18 @@ dependencies {
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.ui:ui-android")
- implementation("androidx.compose.material3:material3:latest.release")
+ implementation("androidx.compose.material3:material3:1.3.0-alpha06")
implementation("androidx.compose.material3:material3-window-size-class")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.material:material")
// All dependencies
- implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1")
implementation("com.google.accompanist:accompanist-swiperefresh:0.24.2-alpha")
// Dagger - Hilt
implementation("com.google.dagger:hilt-android:2.51.1")
- debugImplementation("androidx.compose.ui:ui-tooling:1.6.6")
+ debugImplementation("androidx.compose.ui:ui-tooling:1.6.7")
ksp("com.google.dagger:hilt-android-compiler:2.51")
implementation("com.google.dagger:hilt-compiler:latest.release")
ksp("androidx.hilt:hilt-compiler:latest.release")
@@ -152,4 +159,11 @@ dependencies {
// About open source libraries
implementation("com.mikepenz:aboutlibraries-core:latest.release")
implementation("com.mikepenz:aboutlibraries-compose-m3:latest.release")
+
+ // Translator
+ implementation("com.google.mlkit:translate:latest.release")
+ implementation("com.google.mlkit:language-id:latest.release")
+
+ // Navigation
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/Activity.kt b/app/src/main/java/ua/acclorite/book_story/Activity.kt
index 84fc182d..c0b89360 100644
--- a/app/src/main/java/ua/acclorite/book_story/Activity.kt
+++ b/app/src/main/java/ua/acclorite/book_story/Activity.kt
@@ -25,31 +25,23 @@ import ua.acclorite.book_story.presentation.data.MainViewModel
import ua.acclorite.book_story.presentation.data.NavigationHost
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.screens.about.AboutScreenRoot
-import ua.acclorite.book_story.presentation.screens.about.data.AboutViewModel
import ua.acclorite.book_story.presentation.screens.about.nested.credits.CreditsScreenRoot
import ua.acclorite.book_story.presentation.screens.about.nested.license_info.LicenseInfoScreenRoot
-import ua.acclorite.book_story.presentation.screens.about.nested.license_info.data.LicenseInfoViewModel
import ua.acclorite.book_story.presentation.screens.about.nested.licenses.LicensesScreenRoot
-import ua.acclorite.book_story.presentation.screens.about.nested.licenses.data.LicensesViewModel
import ua.acclorite.book_story.presentation.screens.book_info.BookInfoScreenRoot
-import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel
import ua.acclorite.book_story.presentation.screens.browse.BrowseScreenRoot
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel
import ua.acclorite.book_story.presentation.screens.help.HelpScreenRoot
-import ua.acclorite.book_story.presentation.screens.help.data.HelpViewModel
import ua.acclorite.book_story.presentation.screens.history.HistoryScreenRoot
import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel
import ua.acclorite.book_story.presentation.screens.library.LibraryScreenRoot
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.presentation.screens.reader.ReaderScreenRoot
-import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel
import ua.acclorite.book_story.presentation.screens.settings.SettingsScreenRoot
-import ua.acclorite.book_story.presentation.screens.settings.data.SettingsViewModel
import ua.acclorite.book_story.presentation.screens.settings.nested.appearance.AppearanceSettingsRoot
import ua.acclorite.book_story.presentation.screens.settings.nested.general.GeneralSettingsRoot
import ua.acclorite.book_story.presentation.screens.settings.nested.reader.ReaderSettingsRoot
import ua.acclorite.book_story.presentation.screens.start.StartScreenRoot
-import ua.acclorite.book_story.presentation.screens.start.data.StartViewModel
import ua.acclorite.book_story.presentation.ui.BookStoryTheme
import ua.acclorite.book_story.presentation.ui.Transitions
import ua.acclorite.book_story.presentation.ui.isDark
@@ -66,14 +58,6 @@ class Activity : AppCompatActivity() {
private val libraryViewModel: LibraryViewModel by viewModels()
private val historyViewModel: HistoryViewModel by viewModels()
private val browseViewModel: BrowseViewModel by viewModels()
- private val bookInfoViewModel: BookInfoViewModel by viewModels()
- private val readerViewModel: ReaderViewModel by viewModels()
- private val helpViewModel: HelpViewModel by viewModels()
- private val aboutViewModel: AboutViewModel by viewModels()
- private val licenseInfoViewModel: LicenseInfoViewModel by viewModels()
- private val licensesViewModel: LicensesViewModel by viewModels()
- private val settingsViewModel: SettingsViewModel by viewModels()
- private val startViewModel: StartViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -102,8 +86,8 @@ class Activity : AppCompatActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
- val isLoaded by mainViewModel.isReady.collectAsState()
- val state by mainViewModel.state.collectAsState()
+ val isLoaded = mainViewModel.isReady.collectAsState()
+ val state = mainViewModel.state.collectAsState()
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
@@ -120,15 +104,15 @@ class Activity : AppCompatActivity() {
}
}
- if (isLoaded) {
+ if (isLoaded.value) {
BookStoryTheme(
- theme = state.theme!!,
- isDark = state.darkTheme!!.isDark(),
- isPureDark = state.pureDark!!.isPureDark(this),
- themeContrast = state.themeContrast!!
+ theme = state.value.theme!!,
+ isDark = state.value.darkTheme!!.isDark(),
+ isPureDark = state.value.pureDark!!.isPureDark(this),
+ themeContrast = state.value.themeContrast!!
) {
NavigationHost(
- startScreen = if (state.showStartScreen!!) Screen.START
+ startScreen = if (state.value.showStartScreen!!) Screen.START
else Screen.LIBRARY
) {
navigation(
diff --git a/app/src/main/java/ua/acclorite/book_story/data/di/AppModule.kt b/app/src/main/java/ua/acclorite/book_story/data/di/AppModule.kt
index 396e888a..ed0173ba 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/di/AppModule.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/di/AppModule.kt
@@ -2,6 +2,10 @@ package ua.acclorite.book_story.data.di
import android.app.Application
import androidx.room.Room
+import com.google.mlkit.common.model.RemoteModelManager
+import com.google.mlkit.nl.languageid.LanguageIdentification
+import com.google.mlkit.nl.languageid.LanguageIdentificationOptions
+import com.google.mlkit.nl.languageid.LanguageIdentifier
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
@@ -41,4 +45,20 @@ object AppModule {
"book_db"
).allowMainThreadQueries().build().dao
}
+
+ @Provides
+ @Singleton
+ fun provideModelManager(): RemoteModelManager {
+ return RemoteModelManager.getInstance()
+ }
+
+ @Provides
+ @Singleton
+ fun provideLanguageIdentifier(): LanguageIdentifier {
+ return LanguageIdentification.getClient(
+ LanguageIdentificationOptions.Builder()
+ .setConfidenceThreshold(0.2f)
+ .build()
+ )
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/dto/BookEntity.kt b/app/src/main/java/ua/acclorite/book_story/data/local/dto/BookEntity.kt
index 581eac47..cbd7cd84 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/local/dto/BookEntity.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/local/dto/BookEntity.kt
@@ -1,5 +1,6 @@
package ua.acclorite.book_story.data.local.dto
+import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import ua.acclorite.book_story.domain.model.Category
@@ -16,5 +17,10 @@ data class BookEntity(
val scrollOffset: Int,
val progress: Float,
val image: String? = null,
- val category: Category
+ val category: Category,
+ @ColumnInfo(defaultValue = "false") val enableTranslator: Boolean,
+ @ColumnInfo(defaultValue = "auto") val translateFrom: String,
+ @ColumnInfo(defaultValue = "en") val translateTo: String,
+ @ColumnInfo(defaultValue = "true") val doubleClickTranslation: Boolean,
+ @ColumnInfo(defaultValue = "false") val translateWhenOpen: Boolean
)
diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationService.kt b/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationService.kt
index 93e95033..8ff79222 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationService.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationService.kt
@@ -1,10 +1,10 @@
package ua.acclorite.book_story.data.local.notification
-import ua.acclorite.book_story.data.remote.dto.ReleaseResponse
+import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo
interface UpdatesNotificationService {
- fun postNotification(releaseInfo: ReleaseResponse)
+ fun postNotification(releaseInfo: LatestReleaseInfo)
companion object {
const val CHANNEL_ID = "updates_channel"
diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationServiceImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationServiceImpl.kt
index 09a30dbc..985daeb6 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationServiceImpl.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationServiceImpl.kt
@@ -8,7 +8,7 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import ua.acclorite.book_story.R
-import ua.acclorite.book_story.data.remote.dto.ReleaseResponse
+import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo
import javax.inject.Inject
class UpdatesNotificationServiceImpl @Inject constructor(
@@ -18,7 +18,7 @@ class UpdatesNotificationServiceImpl @Inject constructor(
private val notificationManager = application.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
- override fun postNotification(releaseInfo: ReleaseResponse) {
+ override fun postNotification(releaseInfo: LatestReleaseInfo) {
val downloadLatestVersionIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse(application.getString(R.string.download_latest_release_page))
diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt b/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt
index 232103de..481101c5 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt
@@ -1,5 +1,6 @@
package ua.acclorite.book_story.data.local.room
+import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import ua.acclorite.book_story.data.local.dto.BookEntity
@@ -10,8 +11,11 @@ import ua.acclorite.book_story.data.local.dto.HistoryEntity
BookEntity::class,
HistoryEntity::class
],
- version = 1,
- exportSchema = false
+ version = 2,
+ autoMigrations = [
+ AutoMigration(1, 2)
+ ],
+ exportSchema = true
)
abstract class BookDatabase : RoomDatabase() {
abstract val dao: BookDao
diff --git a/app/src/main/java/ua/acclorite/book_story/data/mapper/book/BookMapperImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/mapper/book/BookMapperImpl.kt
index 2f1e2c9c..8dec9d11 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/mapper/book/BookMapperImpl.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/mapper/book/BookMapperImpl.kt
@@ -20,7 +20,12 @@ class BookMapperImpl @Inject constructor() : BookMapper {
textPath = book.textPath,
description = book.description,
image = if (book.coverImage != null) book.coverImage.toString() else null,
- category = book.category
+ category = book.category,
+ enableTranslator = book.enableTranslator,
+ translateFrom = book.translateFrom,
+ translateTo = book.translateTo,
+ doubleClickTranslation = book.doubleClickTranslation,
+ translateWhenOpen = book.translateWhenOpen
)
}
@@ -39,7 +44,12 @@ class BookMapperImpl @Inject constructor() : BookMapper {
filePath = bookEntity.filePath,
lastOpened = null,
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,
+ enableTranslator = bookEntity.enableTranslator,
+ translateFrom = bookEntity.translateFrom,
+ translateTo = bookEntity.translateTo,
+ doubleClickTranslation = bookEntity.doubleClickTranslation,
+ translateWhenOpen = bookEntity.translateWhenOpen
)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/TextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/TextParser.kt
index 870d523d..586e3dd5 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/parser/TextParser.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/parser/TextParser.kt
@@ -1,9 +1,8 @@
package ua.acclorite.book_story.data.parser
-import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.Resource
import java.io.File
interface TextParser {
- suspend fun parse(file: File): Resource>
+ suspend fun parse(file: File): Resource>
}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/TextParserImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/TextParserImpl.kt
index 76362747..be99e317 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/parser/TextParserImpl.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/parser/TextParserImpl.kt
@@ -4,7 +4,6 @@ import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.epub.EpubTextParser
import ua.acclorite.book_story.data.parser.pdf.PdfTextParser
import ua.acclorite.book_story.data.parser.txt.TxtTextParser
-import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
@@ -15,7 +14,7 @@ class TextParserImpl @Inject constructor(
private val pdfTextParser: PdfTextParser,
private val epubTextParser: EpubTextParser,
) : TextParser {
- override suspend fun parse(file: File): Resource> {
+ override suspend fun parse(file: File): Resource> {
val fileFormat = ".${file.name.substringAfterLast(".")}"
if (fileFormat == ".pdf") {
diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubFileParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubFileParser.kt
index 02d041f0..d86c98f3 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubFileParser.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubFileParser.kt
@@ -55,7 +55,12 @@ class EpubFileParser @Inject constructor() : FileParser {
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
- coverImage = null
+ coverImage = null,
+ enableTranslator = false,
+ translateFrom = "",
+ translateTo = "",
+ doubleClickTranslation = false,
+ translateWhenOpen = false
) to coverImage
} catch (e: Exception) {
return null
diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt
index f5b7f32d..7f54836b 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt
@@ -8,7 +8,6 @@ import org.jsoup.nodes.Document.OutputSettings
import org.jsoup.safety.Safelist
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
-import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import java.io.BufferedReader
@@ -21,7 +20,7 @@ import javax.inject.Inject
class EpubTextParser @Inject constructor() : TextParser {
- override suspend fun parse(file: File): Resource> {
+ override suspend fun parse(file: File): Resource> {
if (!file.name.endsWith(".epub")) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
@@ -57,7 +56,7 @@ class EpubTextParser @Inject constructor() : TextParser {
}
}
- val stringWithIds = mutableListOf()
+ val strings = mutableListOf()
val parsedText = Jsoup.parse(unformattedText.toString())
parsedText.outputSettings(OutputSettings().prettyPrint(false))
@@ -78,15 +77,15 @@ class EpubTextParser @Inject constructor() : TextParser {
.split("\n")
.forEach {
if (it.isNotBlank()) {
- stringWithIds.add(StringWithId(it.trim()))
+ strings.add(it.trim())
}
}
- if (stringWithIds.isEmpty()) {
+ if (strings.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
- return Resource.Success(stringWithIds)
+ return Resource.Success(strings)
} catch (e: Exception) {
return Resource.Error(
UIText.StringResource(
diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfFileParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfFileParser.kt
index e6d797ff..b8ad38cc 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfFileParser.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfFileParser.kt
@@ -43,7 +43,12 @@ class PdfFileParser @Inject constructor(private val application: Application) :
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
- coverImage = null
+ coverImage = null,
+ enableTranslator = false,
+ translateFrom = "",
+ translateTo = "",
+ doubleClickTranslation = false,
+ translateWhenOpen = false
) to null
} catch (e: Exception) {
return null
diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt
index 7ad281db..77a60472 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt
@@ -6,7 +6,6 @@ import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.text.PDFTextStripper
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
-import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
@@ -14,7 +13,7 @@ import javax.inject.Inject
class PdfTextParser @Inject constructor(private val application: Application) : TextParser {
- override suspend fun parse(file: File): Resource> {
+ override suspend fun parse(file: File): Resource> {
if (!file.name.endsWith(".pdf")) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
@@ -23,7 +22,7 @@ class PdfTextParser @Inject constructor(private val application: Application) :
PDFBoxResourceLoader.init(application)
val document = PDDocument.load(file)
- val stringWithIds = mutableListOf()
+ val strings = mutableListOf()
val pdfStripper = PDFTextStripper()
pdfStripper.paragraphStart = ""
@@ -103,14 +102,14 @@ class PdfTextParser @Inject constructor(private val application: Application) :
}
lines.forEach { line ->
- stringWithIds.add(StringWithId(line.trim()))
+ strings.add(line.trim())
}
- if (stringWithIds.isEmpty()) {
+ if (strings.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
- return Resource.Success(stringWithIds)
+ return Resource.Success(strings)
} catch (e: Exception) {
e.printStackTrace()
return Resource.Error(
diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtFileParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtFileParser.kt
index 69c97838..b71d3d3e 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtFileParser.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtFileParser.kt
@@ -31,7 +31,12 @@ class TxtFileParser @Inject constructor() : FileParser {
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
- coverImage = null
+ coverImage = null,
+ enableTranslator = false,
+ translateFrom = "",
+ translateTo = "",
+ doubleClickTranslation = false,
+ translateWhenOpen = false
) to null
} catch (e: Exception) {
return null
diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt
index 303abad7..3f47ab8f 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt
@@ -4,7 +4,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
-import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import java.io.BufferedReader
@@ -14,19 +13,19 @@ import javax.inject.Inject
class TxtTextParser @Inject constructor() : TextParser {
- override suspend fun parse(file: File): Resource> {
+ override suspend fun parse(file: File): Resource> {
if (!file.name.endsWith(".txt")) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
return try {
- val formattedLines = mutableListOf()
+ val formattedLines = mutableListOf()
withContext(Dispatchers.IO) {
BufferedReader(FileReader(file)).forEachLine { line ->
if (line.isNotBlank()) {
formattedLines.add(
- StringWithId(line)
+ line.trim()
)
}
}
diff --git a/app/src/main/java/ua/acclorite/book_story/data/remote/GithubAPI.kt b/app/src/main/java/ua/acclorite/book_story/data/remote/GithubAPI.kt
index fbfd1a90..a6e542d1 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/remote/GithubAPI.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/remote/GithubAPI.kt
@@ -1,11 +1,11 @@
package ua.acclorite.book_story.data.remote
import retrofit2.http.GET
-import ua.acclorite.book_story.data.remote.dto.ReleaseResponse
+import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo
interface GithubAPI {
@GET("repos/Acclorite/book-story/releases/latest")
- suspend fun getLatestRelease(): ReleaseResponse
+ suspend fun getLatestRelease(): LatestReleaseInfo
companion object {
const val BASE_URL = "https://api.github.com/"
diff --git a/app/src/main/java/ua/acclorite/book_story/data/remote/dto/ReleaseResponse.kt b/app/src/main/java/ua/acclorite/book_story/data/remote/dto/LatestReleaseInfo.kt
similarity index 91%
rename from app/src/main/java/ua/acclorite/book_story/data/remote/dto/ReleaseResponse.kt
rename to app/src/main/java/ua/acclorite/book_story/data/remote/dto/LatestReleaseInfo.kt
index 008539c8..b057526c 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/remote/dto/ReleaseResponse.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/remote/dto/LatestReleaseInfo.kt
@@ -8,7 +8,7 @@ import com.squareup.moshi.JsonClass
@Keep
@JsonClass(generateAdapter = true)
@Immutable
-data class ReleaseResponse(
+data class LatestReleaseInfo(
@Json(name = "name") val name: String,
@Json(name = "tag_name") val tagName: String
)
diff --git a/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt
index b222e180..0d21a0f0 100644
--- a/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt
+++ b/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt
@@ -6,6 +6,13 @@ import android.net.Uri
import android.os.Environment
import android.util.Log
import androidx.datastore.preferences.core.Preferences
+import com.google.mlkit.common.model.DownloadConditions
+import com.google.mlkit.common.model.RemoteModelManager
+import com.google.mlkit.nl.languageid.LanguageIdentifier
+import com.google.mlkit.nl.translate.TranslateLanguage
+import com.google.mlkit.nl.translate.TranslateRemoteModel
+import com.google.mlkit.nl.translate.Translation
+import com.google.mlkit.nl.translate.TranslatorOptions
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -24,28 +31,35 @@ import ua.acclorite.book_story.data.mapper.history.HistoryMapper
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.data.remote.GithubAPI
-import ua.acclorite.book_story.data.remote.dto.ReleaseResponse
+import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook
-import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.domain.util.CoverImage
+import ua.acclorite.book_story.domain.util.DataStoreConstants
+import ua.acclorite.book_story.domain.util.LanguageCode
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import ua.acclorite.book_story.presentation.data.MainState
import java.io.File
import java.io.FileOutputStream
+import java.util.Locale
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+@Suppress("DEPRECATION")
@Singleton
class BookRepositoryImpl @Inject constructor(
private val application: Application,
private val database: BookDao,
private val dataStore: DataStore,
+ private val modelManager: RemoteModelManager,
+ private val languageIdentifier: LanguageIdentifier,
private val githubAPI: GithubAPI,
private val updatesNotificationService: UpdatesNotificationService,
@@ -88,7 +102,7 @@ class BookRepositoryImpl @Inject constructor(
}
}
- override suspend fun getBookText(textPath: String): List {
+ override suspend fun getBookText(textPath: String): List {
val textFile = File(textPath)
if (textPath.isBlank() || !textFile.exists()) {
@@ -108,7 +122,7 @@ class BookRepositoryImpl @Inject constructor(
override suspend fun insertBook(
book: Book,
coverImage: CoverImage?,
- text: List
+ text: List
): Boolean {
val filesDir = application.filesDir
val coversDir = File(filesDir, "covers")
@@ -135,7 +149,7 @@ class BookRepositoryImpl @Inject constructor(
withContext(Dispatchers.IO) {
FileOutputStream(textPath).use { stream ->
text.forEach { line ->
- stream.write(line.line.toByteArray())
+ stream.write(line.toByteArray())
stream.write(System.lineSeparator().toByteArray())
}
}
@@ -169,12 +183,34 @@ class BookRepositoryImpl @Inject constructor(
}
}
+ val enableTranslator = dataStore.getNullableData(DataStoreConstants.ENABLE_TRANSLATOR)
+ ?: false
+
+ val translateFrom = dataStore.getNullableData(DataStoreConstants.TRANSLATE_FROM)
+ ?: "auto"
+
+ val translateTo = dataStore.getNullableData(DataStoreConstants.TRANSLATE_TO) ?: if (
+ Constants.LANGUAGES.any { Locale.getDefault().language.take(2) == it.first }
+ ) {
+ Locale.getDefault().language.take(2)
+ } else {
+ "en"
+ }
+
+ val doubleClickTranslation = dataStore.getNullableData(
+ DataStoreConstants.DOUBLE_CLICK_TRANSLATION
+ ) ?: true
+
val updatedBook = book.copy(
textPath = "$booksDir/$textUri",
coverImage = if (coverUri.isNotBlank()) {
Uri.fromFile(File("$coversDir/$coverUri"))
- } else null
+ } else null,
+ enableTranslator = enableTranslator,
+ translateFrom = translateFrom,
+ translateTo = translateTo,
+ doubleClickTranslation = doubleClickTranslation
)
val bookToInsert = bookMapper.toBookEntity(updatedBook)
@@ -197,7 +233,7 @@ class BookRepositoryImpl @Inject constructor(
)
}
- override suspend fun updateBookWithText(book: Book, text: List): Boolean {
+ override suspend fun updateBookWithText(book: Book, text: List): Boolean {
// without cover image
val filesDir = application.filesDir
val booksDir = File(filesDir, "books")
@@ -220,7 +256,7 @@ class BookRepositoryImpl @Inject constructor(
withContext(Dispatchers.IO) {
FileOutputStream(textPath).use { stream ->
text.forEach { line ->
- stream.write(line.line.toByteArray())
+ stream.write(line.toByteArray())
stream.write(System.lineSeparator().toByteArray())
}
}
@@ -604,7 +640,7 @@ class BookRepositoryImpl @Inject constructor(
database.deleteHistory(history.map { historyMapper.toHistoryEntity(it) })
}
- override suspend fun checkForUpdates(postNotification: Boolean): ReleaseResponse? {
+ override suspend fun checkForUpdates(postNotification: Boolean): LatestReleaseInfo? {
return withContext(Dispatchers.IO) {
try {
val result = githubAPI.getLatestRelease()
@@ -625,6 +661,155 @@ class BookRepositoryImpl @Inject constructor(
}
}
}
+
+ override suspend fun isLanguageModelDownloaded(languageCode: String): Boolean {
+ val model = TranslateRemoteModel.Builder(languageCode).build()
+ return suspendCoroutine { continuation ->
+ modelManager.isModelDownloaded(model)
+ .addOnSuccessListener {
+ continuation.resume(it)
+ }
+ .addOnFailureListener {
+ continuation.resume(false)
+ }
+ }
+ }
+
+ override suspend fun downloadLanguageModel(
+ languageCode: String,
+ onCompleted: () -> Unit,
+ onFailure: (Exception) -> Unit
+ ) {
+ val isLanguageDownloaded = isLanguageModelDownloaded(languageCode)
+ if (isLanguageDownloaded) {
+ onCompleted()
+ return
+ }
+
+ return suspendCoroutine { continuation ->
+ val language = TranslateRemoteModel.Builder(languageCode).build()
+ val conditions = DownloadConditions.Builder().build()
+
+ modelManager.download(language, conditions)
+ .addOnSuccessListener {
+ onCompleted()
+ continuation.resume(Unit)
+ }
+ .addOnFailureListener {
+ onFailure(it)
+ onCompleted()
+ continuation.resume(Unit)
+ }
+ .addOnCanceledListener {
+ onCompleted()
+ Log.i("TRANSLATOR", "Downloading Model: Canceled")
+ continuation.resume(Unit)
+ }
+ }
+ }
+
+ override suspend fun deleteLanguageModel(languageCode: String): Boolean {
+ val isLanguageDownloaded = isLanguageModelDownloaded(languageCode)
+ if (!isLanguageDownloaded) {
+ return true
+ }
+
+ return suspendCoroutine { continuation ->
+ val language = TranslateRemoteModel.Builder(languageCode).build()
+
+ modelManager.deleteDownloadedModel(language)
+ .addOnSuccessListener {
+ continuation.resume(true)
+ }
+ .addOnFailureListener {
+ continuation.resume(false)
+ }
+ }
+ }
+
+ override suspend fun identifyLanguage(text: String): Resource {
+ return suspendCoroutine { continuation ->
+ languageIdentifier.identifyLanguage(text)
+ .addOnSuccessListener {
+ val languageCode = it.trim().lowercase().take(2)
+ var identified = true
+
+ if (languageCode == "ru") {
+ identified = false
+ }
+
+ if (TranslateLanguage.fromLanguageTag(languageCode) == null) {
+ identified = false
+ }
+
+ if (identified) {
+ continuation.resume(
+ Resource.Success(languageCode)
+ )
+ return@addOnSuccessListener
+ }
+
+ continuation.resume(
+ Resource.Error(UIText.StringResource(R.string.error_language_not_identified))
+ )
+ }
+ .addOnFailureListener {
+ continuation.resume(
+ Resource.Error(
+ UIText.StringResource(
+ R.string.error_query,
+ it.message ?: ""
+ )
+ )
+ )
+ }
+ }
+ }
+
+ override suspend fun translateText(
+ sourceLanguage: LanguageCode,
+ targetLanguage: LanguageCode,
+ text: String
+ ): Resource {
+ return suspendCoroutine { continuation ->
+ val translatorOptions = TranslatorOptions.Builder()
+ .setSourceLanguage(sourceLanguage)
+ .setTargetLanguage(targetLanguage)
+ .build()
+ val translator = Translation.getClient(translatorOptions)
+
+ translator.translate(text)
+ .addOnSuccessListener {
+ translator.close()
+ continuation.resume(
+ Resource.Success(
+ it.trim()
+ )
+ )
+ }
+ .addOnFailureListener {
+ translator.close()
+ continuation.resume(
+ Resource.Error(
+ UIText.StringResource(
+ R.string.error_query,
+ it.message ?: ""
+ )
+ )
+ )
+ }
+ .addOnCanceledListener {
+ translator.close()
+ continuation.resume(
+ Resource.Error(
+ UIText.StringResource(
+ R.string.error_something_went_wrong
+ )
+ )
+ )
+ }
+ }
+ }
}
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/Book.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/Book.kt
index f6d5ba17..f2762588 100644
--- a/app/src/main/java/ua/acclorite/book_story/domain/model/Book.kt
+++ b/app/src/main/java/ua/acclorite/book_story/domain/model/Book.kt
@@ -22,4 +22,10 @@ data class Book(
val lastOpened: Long?,
val category: Category,
+
+ val enableTranslator: Boolean,
+ val translateFrom: String,
+ val translateTo: String,
+ val doubleClickTranslation: Boolean,
+ val translateWhenOpen: Boolean
)
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/Language.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/Language.kt
new file mode 100644
index 00000000..e8ec13a2
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/domain/model/Language.kt
@@ -0,0 +1,31 @@
+package ua.acclorite.book_story.domain.model
+
+import androidx.compose.runtime.Immutable
+import ua.acclorite.book_story.domain.util.ID
+import ua.acclorite.book_story.domain.util.Selected
+import ua.acclorite.book_story.domain.util.UIText
+
+@Immutable
+data class Language(
+ val languageCode: String,
+ val displayLanguage: String,
+ val isDownloading: Boolean,
+ val isCanceled: Boolean,
+ val isDownloaded: Boolean,
+ val isSelected: Selected,
+ val historyOrder: Int?
+)
+
+@Immutable
+data class SelectableLanguage(
+ val languageCode: String,
+ val displayLanguage: String,
+ val isDownloading: Boolean,
+ val isCanceled: Boolean,
+ val isDownloaded: Boolean,
+ val isError: Boolean,
+ val errorMessage: UIText? = null,
+ val isSelected: Selected,
+ val canUnselect: Boolean,
+ val occurrences: List?
+)
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/StringWithId.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/LanguageHistory.kt
similarity index 61%
rename from app/src/main/java/ua/acclorite/book_story/domain/model/StringWithId.kt
rename to app/src/main/java/ua/acclorite/book_story/domain/model/LanguageHistory.kt
index f8e99868..70057589 100644
--- a/app/src/main/java/ua/acclorite/book_story/domain/model/StringWithId.kt
+++ b/app/src/main/java/ua/acclorite/book_story/domain/model/LanguageHistory.kt
@@ -3,11 +3,10 @@ package ua.acclorite.book_story.domain.model
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize
-import java.util.UUID
@Parcelize
@Immutable
-data class StringWithId(
- val line: String,
- val id: String = UUID.randomUUID().toString()
+data class LanguageHistory(
+ val id: Int,
+ val languageCode: String
) : Parcelable
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/LineWithTranslation.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/LineWithTranslation.kt
new file mode 100644
index 00000000..0b636587
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/domain/model/LineWithTranslation.kt
@@ -0,0 +1,21 @@
+package ua.acclorite.book_story.domain.model
+
+import android.os.Parcelable
+import androidx.compose.runtime.Immutable
+import kotlinx.parcelize.Parcelize
+import ua.acclorite.book_story.domain.util.UIText
+
+@Parcelize
+@Immutable
+data class LineWithTranslation(
+ val originalLine: String,
+
+ val useTranslation: Boolean = false,
+ val translatingFrom: String = "",
+ val translatingTo: String = "",
+ val translatedLine: String? = null,
+
+ val isTranslationLoading: Boolean = false,
+ val isTranslationFailed: Boolean = false,
+ val errorMessage: UIText? = null
+) : Parcelable
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/NavigationItem.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/NavigationItem.kt
index 3e62e80c..a68105f9 100644
--- a/app/src/main/java/ua/acclorite/book_story/domain/model/NavigationItem.kt
+++ b/app/src/main/java/ua/acclorite/book_story/domain/model/NavigationItem.kt
@@ -1,11 +1,15 @@
package ua.acclorite.book_story.domain.model
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable
-import androidx.compose.ui.graphics.painter.Painter
+import ua.acclorite.book_story.presentation.data.Screen
@Immutable
data class NavigationItem(
- val title: String,
- val selectedIcon: Painter,
- val unselectedIcon: Painter
+ val screen: Screen,
+ @StringRes val title: Int,
+ @StringRes val tooltip: Int,
+ @DrawableRes val selectedIcon: Int,
+ @DrawableRes val unselectedIcon: Int
)
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/NullableBook.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/NullableBook.kt
index add5bfe2..0d294faf 100644
--- a/app/src/main/java/ua/acclorite/book_story/domain/model/NullableBook.kt
+++ b/app/src/main/java/ua/acclorite/book_story/domain/model/NullableBook.kt
@@ -8,14 +8,14 @@ import ua.acclorite.book_story.domain.util.UIText
sealed class NullableBook(
val book: Book?,
val coverImage: CoverImage? = null,
- val text: List = emptyList(),
+ val text: List = emptyList(),
val fileName: String?,
val message: UIText?
) {
class NotNull(
book: Book,
coverImage: CoverImage?,
- text: List
+ text: List
) : NullableBook(
book = book,
text = text,
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt b/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt
index a2276cf0..80103010 100644
--- a/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt
+++ b/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt
@@ -3,12 +3,12 @@ package ua.acclorite.book_story.domain.repository
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
-import ua.acclorite.book_story.data.remote.dto.ReleaseResponse
+import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook
-import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.CoverImage
+import ua.acclorite.book_story.domain.util.LanguageCode
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.presentation.data.MainState
import java.io.File
@@ -25,12 +25,12 @@ interface BookRepository {
suspend fun getBookText(
textPath: String
- ): List
+ ): List
suspend fun insertBook(
book: Book,
coverImage: CoverImage?,
- text: List
+ text: List
): Boolean
suspend fun updateBooks(
@@ -39,7 +39,7 @@ interface BookRepository {
suspend fun updateBookWithText(
book: Book,
- text: List
+ text: List
): Boolean
suspend fun updateCoverImageOfBook(
@@ -51,6 +51,7 @@ interface BookRepository {
books: List
)
+
suspend fun retrieveDataFromDataStore(
key: Preferences.Key,
defaultValue: T
@@ -61,12 +62,14 @@ interface BookRepository {
value: T
)
+
suspend fun getAllSettings(scope: CoroutineScope): MainState
suspend fun getFilesFromDevice(query: String = ""): Flow>>
suspend fun getBooksFromFiles(files: List): List
+
suspend fun insertHistory(history: List)
suspend fun getHistory(): Flow>>
@@ -81,5 +84,25 @@ interface BookRepository {
history: List
)
- suspend fun checkForUpdates(postNotification: Boolean): ReleaseResponse?
+
+ suspend fun checkForUpdates(postNotification: Boolean): LatestReleaseInfo?
+
+
+ suspend fun isLanguageModelDownloaded(languageCode: String): Boolean
+
+ suspend fun downloadLanguageModel(
+ languageCode: String,
+ onCompleted: () -> Unit,
+ onFailure: (Exception) -> Unit
+ )
+
+ suspend fun deleteLanguageModel(languageCode: String): Boolean
+
+ suspend fun identifyLanguage(text: String): Resource
+
+ suspend fun translateText(
+ sourceLanguage: LanguageCode,
+ targetLanguage: LanguageCode,
+ text: String
+ ): Resource
}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/CheckForUpdates.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/CheckForUpdates.kt
index e8177898..486dab23 100644
--- a/app/src/main/java/ua/acclorite/book_story/domain/use_case/CheckForUpdates.kt
+++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/CheckForUpdates.kt
@@ -1,12 +1,12 @@
package ua.acclorite.book_story.domain.use_case
-import ua.acclorite.book_story.data.remote.dto.ReleaseResponse
+import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo
import ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Inject
class CheckForUpdates @Inject constructor(private val repository: BookRepository) {
- suspend fun execute(postNotification: Boolean): ReleaseResponse? {
+ suspend fun execute(postNotification: Boolean): LatestReleaseInfo? {
return repository.checkForUpdates(postNotification)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/DeleteLanguageModel.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/DeleteLanguageModel.kt
new file mode 100644
index 00000000..c1fa11be
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/DeleteLanguageModel.kt
@@ -0,0 +1,15 @@
+package ua.acclorite.book_story.domain.use_case
+
+import ua.acclorite.book_story.domain.repository.BookRepository
+import javax.inject.Inject
+
+class DeleteLanguageModel @Inject constructor(
+ private val repository: BookRepository
+) {
+
+ suspend fun execute(languageCode: String): Boolean {
+ return repository.deleteLanguageModel(
+ languageCode = languageCode
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/DownloadLanguageModel.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/DownloadLanguageModel.kt
new file mode 100644
index 00000000..b9101483
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/DownloadLanguageModel.kt
@@ -0,0 +1,21 @@
+package ua.acclorite.book_story.domain.use_case
+
+import ua.acclorite.book_story.domain.repository.BookRepository
+import javax.inject.Inject
+
+class DownloadLanguageModel @Inject constructor(
+ private val repository: BookRepository
+) {
+
+ suspend fun execute(
+ languageCode: String,
+ onCompleted: () -> Unit,
+ onFailure: (Exception) -> Unit
+ ) {
+ repository.downloadLanguageModel(
+ languageCode = languageCode,
+ onCompleted = onCompleted,
+ onFailure = onFailure
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetText.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetText.kt
index db3d6d3e..03c88ceb 100644
--- a/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetText.kt
+++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetText.kt
@@ -1,12 +1,15 @@
package ua.acclorite.book_story.domain.use_case
-import ua.acclorite.book_story.domain.model.StringWithId
+import ua.acclorite.book_story.domain.model.LineWithTranslation
import ua.acclorite.book_story.domain.repository.BookRepository
+import ua.acclorite.book_story.domain.util.ID
import javax.inject.Inject
class GetText @Inject constructor(private val repository: BookRepository) {
- suspend fun execute(textPath: String): List {
- return repository.getBookText(textPath = textPath)
+ suspend fun execute(textPath: String): Map {
+ return repository.getBookText(textPath = textPath).withIndex().associate {
+ it.index to LineWithTranslation(originalLine = it.value)
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/IdentifyLanguage.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/IdentifyLanguage.kt
new file mode 100644
index 00000000..8050d985
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/IdentifyLanguage.kt
@@ -0,0 +1,15 @@
+package ua.acclorite.book_story.domain.use_case
+
+import ua.acclorite.book_story.domain.repository.BookRepository
+import ua.acclorite.book_story.domain.util.LanguageCode
+import ua.acclorite.book_story.domain.util.Resource
+import javax.inject.Inject
+
+class IdentifyLanguage @Inject constructor(
+ private val repository: BookRepository
+) {
+
+ suspend fun execute(text: String): Resource {
+ return repository.identifyLanguage(text)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/InsertBook.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/InsertBook.kt
index 87db836d..679443e4 100644
--- a/app/src/main/java/ua/acclorite/book_story/domain/use_case/InsertBook.kt
+++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/InsertBook.kt
@@ -1,13 +1,16 @@
package ua.acclorite.book_story.domain.use_case
import ua.acclorite.book_story.domain.model.Book
-import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.domain.util.CoverImage
import javax.inject.Inject
class InsertBook @Inject constructor(private val repository: BookRepository) {
- suspend fun execute(book: Book, coverImage: CoverImage?, text: List): Boolean {
+ suspend fun execute(
+ book: Book,
+ coverImage: CoverImage?,
+ text: List
+ ): Boolean {
return repository.insertBook(book, coverImage, text)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/IsLanguageModelDownloaded.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/IsLanguageModelDownloaded.kt
new file mode 100644
index 00000000..1b99e7f5
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/IsLanguageModelDownloaded.kt
@@ -0,0 +1,13 @@
+package ua.acclorite.book_story.domain.use_case
+
+import ua.acclorite.book_story.domain.repository.BookRepository
+import javax.inject.Inject
+
+class IsLanguageModelDownloaded @Inject constructor(
+ private val repository: BookRepository
+) {
+
+ suspend fun execute(languageCode: String): Boolean {
+ return repository.isLanguageModelDownloaded(languageCode)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/TranslateText.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/TranslateText.kt
new file mode 100644
index 00000000..d4dcdb26
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/TranslateText.kt
@@ -0,0 +1,23 @@
+package ua.acclorite.book_story.domain.use_case
+
+import ua.acclorite.book_story.domain.repository.BookRepository
+import ua.acclorite.book_story.domain.util.LanguageCode
+import ua.acclorite.book_story.domain.util.Resource
+import javax.inject.Inject
+
+class TranslateText @Inject constructor(
+ private val repository: BookRepository
+) {
+
+ suspend fun execute(
+ sourceLanguage: LanguageCode,
+ targetLanguage: LanguageCode,
+ text: String
+ ): Resource {
+ return repository.translateText(
+ sourceLanguage,
+ targetLanguage,
+ text
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBookWithText.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBookWithText.kt
index e44ac5a8..5640f275 100644
--- a/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBookWithText.kt
+++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBookWithText.kt
@@ -1,13 +1,12 @@
package ua.acclorite.book_story.domain.use_case
import ua.acclorite.book_story.domain.model.Book
-import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Inject
class UpdateBookWithText @Inject constructor(private val repository: BookRepository) {
- suspend fun execute(book: Book, text: List): Boolean {
+ suspend fun execute(book: Book, text: List): Boolean {
return repository.updateBookWithText(book, text)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/util/Constants.kt b/app/src/main/java/ua/acclorite/book_story/domain/util/Constants.kt
index 6d9f0763..d717ba21 100644
--- a/app/src/main/java/ua/acclorite/book_story/domain/util/Constants.kt
+++ b/app/src/main/java/ua/acclorite/book_story/domain/util/Constants.kt
@@ -9,6 +9,8 @@ import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.model.Credit
import ua.acclorite.book_story.domain.model.FontWithName
+import ua.acclorite.book_story.domain.model.NavigationItem
+import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.ui.Theme
object Constants {
@@ -25,6 +27,31 @@ object Constants {
Pair("uk", "Українська"),
)
+ // Navigation items for NavigationBars.
+ val NAVIGATION_ITEMS = listOf(
+ NavigationItem(
+ screen = Screen.LIBRARY,
+ title = R.string.library_screen,
+ tooltip = R.string.library_content_desc,
+ selectedIcon = R.drawable.library_screen_filled,
+ unselectedIcon = R.drawable.library_screen_outlined
+ ),
+ NavigationItem(
+ screen = Screen.HISTORY,
+ title = R.string.history_screen,
+ tooltip = R.string.history_content_desc,
+ selectedIcon = R.drawable.history_screen_filled,
+ unselectedIcon = R.drawable.history_screen_outlined
+ ),
+ NavigationItem(
+ screen = Screen.BROWSE,
+ title = R.string.browse_screen,
+ tooltip = R.string.browse_content_desc,
+ selectedIcon = R.drawable.browse_screen_filled,
+ unselectedIcon = R.drawable.browse_screen_outlined
+ )
+ )
+
// Supported themes
val THEMES = listOf(
Pair(Theme.DYNAMIC, UIText.StringResource(R.string.dynamic_theme)),
@@ -255,6 +282,11 @@ object Constants {
scrollOffset = 0,
progress = 0f,
lastOpened = null,
- category = Category.READING
+ category = Category.READING,
+ enableTranslator = false,
+ translateFrom = "",
+ translateTo = "",
+ doubleClickTranslation = false,
+ translateWhenOpen = false
)
}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/util/DataStoreConstants.kt b/app/src/main/java/ua/acclorite/book_story/domain/util/DataStoreConstants.kt
index 458424e4..630b1b17 100644
--- a/app/src/main/java/ua/acclorite/book_story/domain/util/DataStoreConstants.kt
+++ b/app/src/main/java/ua/acclorite/book_story/domain/util/DataStoreConstants.kt
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
object DataStoreConstants {
+ // App settings
val LANGUAGE = stringPreferencesKey("language")
val THEME = stringPreferencesKey("theme")
val DARK_THEME = stringPreferencesKey("dark_theme")
@@ -22,4 +23,9 @@ object DataStoreConstants {
val PARAGRAPH_INDENTATION = booleanPreferencesKey("paragraph_indentation")
val CHECK_FOR_UPDATES = booleanPreferencesKey("check_for_updates")
val SIDE_PADDING = intPreferencesKey("side_padding")
+ val ENABLE_TRANSLATOR = booleanPreferencesKey("enable_translator")
+ val TRANSLATE_FROM = stringPreferencesKey("translate_from")
+ val TRANSLATE_TO = stringPreferencesKey("translate_to")
+ val DOUBLE_CLICK_TRANSLATION = booleanPreferencesKey("double_click_translation")
+ val TRANSLATOR_LANGUAGE_HISTORY = stringPreferencesKey("translator_languages_history")
}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/util/Typealias.kt b/app/src/main/java/ua/acclorite/book_story/domain/util/Typealias.kt
index 7855cb8a..cb84a09a 100644
--- a/app/src/main/java/ua/acclorite/book_story/domain/util/Typealias.kt
+++ b/app/src/main/java/ua/acclorite/book_story/domain/util/Typealias.kt
@@ -3,4 +3,7 @@ package ua.acclorite.book_story.domain.util
import android.graphics.Bitmap
typealias CoverImage = Bitmap
-typealias Selected = Boolean
\ No newline at end of file
+typealias Selected = Boolean
+typealias ID = Int
+typealias LanguageCode = String
+typealias Route = String
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/domain/util/UIText.kt b/app/src/main/java/ua/acclorite/book_story/domain/util/UIText.kt
index a95beeea..0c396d5f 100644
--- a/app/src/main/java/ua/acclorite/book_story/domain/util/UIText.kt
+++ b/app/src/main/java/ua/acclorite/book_story/domain/util/UIText.kt
@@ -1,14 +1,19 @@
package ua.acclorite.book_story.domain.util
import android.content.Context
+import android.os.Parcelable
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
+import kotlinx.parcelize.Parcelize
+import java.io.Serializable
-
-sealed class UIText(val string: String?) {
+@Parcelize
+@Immutable
+sealed class UIText(val string: String?) : Parcelable {
data class StringValue(val value: String) : UIText(value)
- class StringResource(@StringRes val resId: Int, vararg val args: Any) : UIText(null)
+ class StringResource(@StringRes val resId: Int, vararg val args: Serializable) : UIText(null)
@Composable
fun asString(): String {
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomBottomSheet.kt
new file mode 100644
index 00000000..9c17e0ef
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomBottomSheet.kt
@@ -0,0 +1,71 @@
+package ua.acclorite.book_story.presentation.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+
+/**
+ * Custom Bottom Sheet. Fixes bugs with [ModalBottomSheet].
+ *
+ * @param modifier [Modifier].
+ * @param hasFixedHeight Whether the bottom sheet has fixed height passed through [modifier] or not.
+ * @param scrimColor Scrim color.
+ * @param shape Shape.
+ * @param containerColor Container color.
+ * @param onDismissRequest OnDismiss callback.
+ * @param dragHandle Drag Handle, pass null to disable.
+ * @param content Content inside [ModalBottomSheet]. Provides you navigation bar padding.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CustomBottomSheet(
+ modifier: Modifier = Modifier,
+ hasFixedHeight: Boolean = false,
+ scrimColor: Color = BottomSheetDefaults.ScrimColor,
+ shape: Shape = BottomSheetDefaults.ExpandedShape,
+ containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
+ onDismissRequest: () -> Unit,
+ dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
+ content: @Composable ColumnScope.(navigationBarPadding: Dp) -> Unit
+) {
+ val navigationBarPadding =
+ WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+
+ Box(Modifier.fillMaxSize()) {
+ ModalBottomSheet(
+ scrimColor = scrimColor,
+ dragHandle = dragHandle,
+ modifier = Modifier
+ .then(
+ if (hasFixedHeight) Modifier.align(Alignment.BottomCenter)
+ else Modifier
+ )
+ .fillMaxWidth()
+ .then(modifier),
+ onDismissRequest = {
+ onDismissRequest()
+ },
+ shape = shape,
+ sheetState = rememberModalBottomSheetState(true),
+ contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
+ containerColor = containerColor
+ ) {
+ content(navigationBarPadding)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomCheckbox.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomCheckbox.kt
index 5390f7d8..48bfa527 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomCheckbox.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomCheckbox.kt
@@ -11,7 +11,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
@@ -20,14 +19,21 @@ import ua.acclorite.book_story.R
/**
* Custom Checkbox. Has a Circle shape.
+ *
+ * @param selected Whether checked or not.
+ * @param containerColor Container color.
+ * @param size Size of the checkbox icon(not the whole checkbox).
*/
@Composable
-fun CustomCheckbox(selected: Boolean, containerColor: Color, size: Dp = 22.dp) {
+fun CustomCheckbox(
+ selected: Boolean,
+ containerColor: Color,
+ size: Dp = 22.dp
+) {
Icon(
imageVector = Icons.Default.Check,
tint = if (selected) containerColor else Color.Transparent,
modifier = Modifier
- .clip(CircleShape)
.border(
width = if (!selected) 1.dp else 0.dp,
if (!selected) MaterialTheme.colorScheme.outline else Color.Transparent,
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomSelectionContainer.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomSelectionContainer.kt
index 00ec3070..8e96079c 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomSelectionContainer.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomSelectionContainer.kt
@@ -1,3 +1,5 @@
+@file:Suppress("DEPRECATION")
+
package ua.acclorite.book_story.presentation.components
import android.content.ClipData
@@ -7,6 +9,7 @@ import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.View
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -15,6 +18,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalTextToolbar
@@ -42,13 +46,13 @@ private class CustomTextActionModeCallback(
requireNotNull(menu)
requireNotNull(mode)
- onTranslateRequested?.let {
- menu.add(0, MENU_ITEM_TRANSLATE, 0, context.getString(R.string.translate))
+ onCopyRequested?.let {
+ menu.add(0, MENU_ITEM_COPY, 0, context.getString(R.string.copy))
.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER)
}
- onCopyRequested?.let {
- menu.add(0, MENU_ITEM_COPY, 1, context.getString(R.string.copy))
+ onTranslateRequested?.let {
+ menu.add(0, MENU_ITEM_TRANSLATE, 1, context.getString(R.string.translate))
.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER)
}
@@ -233,7 +237,7 @@ fun CustomSelectionContainer(
CompositionLocalProvider(
LocalTextToolbar provides customSelectionToolbar
) {
- SelectionContainer {
+ SelectionContainer(Modifier.fillMaxSize()) {
content(isToolbarHidden.value)
}
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationBar.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationBar.kt
index a7c44d8c..45d02e0f 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationBar.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationBar.kt
@@ -7,10 +7,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import ua.acclorite.book_story.R
-import ua.acclorite.book_story.domain.model.NavigationItem
+import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.presentation.data.LocalNavigator
import ua.acclorite.book_story.presentation.data.Screen
@@ -31,41 +28,13 @@ fun BottomNavigationBar() {
}
NavigationBar {
-
- BottomNavigationBarItem(
- item = NavigationItem(
- stringResource(id = R.string.library_screen),
- selectedIcon = painterResource(id = R.drawable.library_screen_filled),
- unselectedIcon = painterResource(id = R.drawable.library_screen_outlined)
- ),
- tooltipText = stringResource(id = R.string.library_content_desc),
- isSelected = currentScreen == Screen.LIBRARY
- ) {
- navigator.navigate(Screen.LIBRARY, false)
- }
-
- BottomNavigationBarItem(
- item = NavigationItem(
- stringResource(id = R.string.history_screen),
- selectedIcon = painterResource(id = R.drawable.history_screen_filled),
- unselectedIcon = painterResource(id = R.drawable.history_screen_outlined)
- ),
- tooltipText = stringResource(id = R.string.history_content_desc),
- isSelected = currentScreen == Screen.HISTORY
- ) {
- navigator.navigate(Screen.HISTORY, false)
- }
-
- BottomNavigationBarItem(
- item = NavigationItem(
- stringResource(id = R.string.browse_screen),
- selectedIcon = painterResource(id = R.drawable.browse_screen_filled),
- unselectedIcon = painterResource(id = R.drawable.browse_screen_outlined)
- ),
- tooltipText = stringResource(id = R.string.browse_content_desc),
- isSelected = currentScreen == Screen.BROWSE
- ) {
- navigator.navigate(Screen.BROWSE, false)
+ Constants.NAVIGATION_ITEMS.forEach {
+ BottomNavigationBarItem(
+ item = it,
+ isSelected = currentScreen == it.screen
+ ) {
+ navigator.navigate(it.screen, false)
+ }
}
}
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationItem.kt
index f00b92c6..decbebb4 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationItem.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationItem.kt
@@ -8,6 +8,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.model.NavigationItem
@@ -15,12 +17,16 @@ import ua.acclorite.book_story.presentation.components.CustomTooltip
/**
* Bottom Navigation Bar Item, uses default [NavigationBarItem].
+ *
+ * @param modifier Modifier.
+ * @param item [NavigationItem].
+ * @param isSelected Whether this screen is currently selected.
+ * @param onClick OnClick callback.
*/
@Composable
fun RowScope.BottomNavigationBarItem(
modifier: Modifier = Modifier,
item: NavigationItem,
- tooltipText: String,
isSelected: Boolean,
onClick: () -> Unit
) {
@@ -33,11 +39,11 @@ fun RowScope.BottomNavigationBarItem(
NavigationBarItem(
label = {
CustomTooltip(
- text = tooltipText,
+ text = stringResource(id = item.tooltip),
padding = 64.dp
) {
Text(
- text = item.title,
+ text = stringResource(id = item.title),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -47,11 +53,11 @@ fun RowScope.BottomNavigationBarItem(
onClick = { onClick() },
icon = {
CustomTooltip(
- text = tooltipText,
+ text = stringResource(id = item.tooltip),
padding = 32.dp
) {
Icon(
- painter = icon,
+ painter = painterResource(id = icon),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_dialog/CustomDialogWithLazyColumn.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_dialog/CustomDialogWithLazyColumn.kt
index 5ec0abec..521d1229 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_dialog/CustomDialogWithLazyColumn.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_dialog/CustomDialogWithLazyColumn.kt
@@ -2,7 +2,6 @@ package ua.acclorite.book_story.presentation.components.custom_dialog
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -57,7 +56,7 @@ import ua.acclorite.book_story.R
* @param withDivider Should divider be shown or not.
* @param items The items of this dialog, use items(..) { .. } or item { .. }.
*/
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomDialogWithLazyColumn(
modifier: Modifier = Modifier,
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRail.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRail.kt
index d6123fb7..81eb2e0f 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRail.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRail.kt
@@ -22,11 +22,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import ua.acclorite.book_story.R
-import ua.acclorite.book_story.domain.model.NavigationItem
+import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.presentation.data.LocalNavigator
import ua.acclorite.book_story.presentation.data.Screen
@@ -69,38 +66,13 @@ fun BoxScope.CustomNavigationRail() {
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally
) {
- CustomNavigationRailItem(
- item = NavigationItem(
- stringResource(id = R.string.library_screen),
- selectedIcon = painterResource(id = R.drawable.library_screen_filled),
- unselectedIcon = painterResource(id = R.drawable.library_screen_outlined)
- ),
- tooltipText = stringResource(id = R.string.library_content_desc),
- isSelected = currentScreen == Screen.LIBRARY
- ) {
- navigator.navigate(Screen.LIBRARY, false)
- }
- CustomNavigationRailItem(
- item = NavigationItem(
- stringResource(id = R.string.history_screen),
- selectedIcon = painterResource(id = R.drawable.history_screen_filled),
- unselectedIcon = painterResource(id = R.drawable.history_screen_outlined)
- ),
- tooltipText = stringResource(id = R.string.history_content_desc),
- isSelected = currentScreen == Screen.HISTORY
- ) {
- navigator.navigate(Screen.HISTORY, false)
- }
- CustomNavigationRailItem(
- item = NavigationItem(
- stringResource(id = R.string.browse_screen),
- selectedIcon = painterResource(id = R.drawable.browse_screen_filled),
- unselectedIcon = painterResource(id = R.drawable.browse_screen_outlined)
- ),
- tooltipText = stringResource(id = R.string.browse_content_desc),
- isSelected = currentScreen == Screen.BROWSE
- ) {
- navigator.navigate(Screen.BROWSE, false)
+ Constants.NAVIGATION_ITEMS.forEach {
+ CustomNavigationRailItem(
+ item = it,
+ isSelected = currentScreen == it.screen
+ ) {
+ navigator.navigate(it.screen, false)
+ }
}
}
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRailItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRailItem.kt
index 8d2bae4e..c8afd049 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRailItem.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRailItem.kt
@@ -7,6 +7,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.model.NavigationItem
@@ -14,12 +16,16 @@ import ua.acclorite.book_story.presentation.components.CustomTooltip
/**
* Custom Navigation Rail item.
+ *
+ * @param modifier Modifier.
+ * @param item [NavigationItem].
+ * @param isSelected Whether this screen is currently selected.
+ * @param onClick OnClick callback.
*/
@Composable
fun CustomNavigationRailItem(
modifier: Modifier = Modifier,
item: NavigationItem,
- tooltipText: String,
isSelected: Boolean,
onClick: () -> Unit
) {
@@ -31,11 +37,11 @@ fun CustomNavigationRailItem(
NavigationRailItem(
label = {
CustomTooltip(
- text = tooltipText,
+ text = stringResource(id = item.tooltip),
padding = 48.dp
) {
Text(
- text = item.title,
+ text = stringResource(id = item.title),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -45,11 +51,11 @@ fun CustomNavigationRailItem(
onClick = { onClick() },
icon = {
CustomTooltip(
- text = tooltipText,
+ text = stringResource(id = item.tooltip),
padding = 16.dp
) {
Icon(
- painter = icon,
+ painter = painterResource(id = icon),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/TranslatorLanguageBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/TranslatorLanguageBottomSheet.kt
new file mode 100644
index 00000000..c5a6c6f0
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/TranslatorLanguageBottomSheet.kt
@@ -0,0 +1,320 @@
+package ua.acclorite.book_story.presentation.components.translator_language
+
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AutoAwesome
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import ua.acclorite.book_story.R
+import ua.acclorite.book_story.domain.model.LanguageHistory
+import ua.acclorite.book_story.presentation.components.CategoryTitle
+import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility
+import ua.acclorite.book_story.presentation.components.CustomBottomSheet
+import ua.acclorite.book_story.presentation.components.customItems
+import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty
+import ua.acclorite.book_story.presentation.components.translator_language.components.TranslatorLanguageDownloadDialog
+import ua.acclorite.book_story.presentation.components.translator_language.components.TranslatorLanguageItem
+import ua.acclorite.book_story.presentation.components.translator_language.components.TranslatorLanguageItemWithIcon
+import ua.acclorite.book_story.presentation.components.translator_language.components.TranslatorLanguageTopBar
+import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageEvent
+import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageState
+import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageViewModel
+import ua.acclorite.book_story.presentation.data.MainEvent
+import ua.acclorite.book_story.presentation.data.MainViewModel
+import ua.acclorite.book_story.presentation.ui.DefaultTransition
+import ua.acclorite.book_story.presentation.ui.Transitions
+
+/**
+ * Translator language bottom sheet.
+ * Manages downloading, deleting and selecting language for translator.
+ *
+ * @param selectedLanguage Current selected language.
+ * @param unselectedLanguage Current unselected language(second).
+ * @param translateFromSelecting Whether user selects "Translate from" or "Translate to" language.
+ * @param onDismiss Dismissing BottomSheet.
+ * @param onSelect Gives two variables, first - Translate from, second - Translate to. If user selects "Translate from" and then selects current unselected language(in this case "Translate to"), then it also changes "Translate to" to previous "Translate from" value.
+ */
+@Composable
+fun TranslatorLanguageBottomSheet(
+ selectedLanguage: String,
+ unselectedLanguage: String,
+ translateFromSelecting: Boolean,
+ onDismiss: () -> Unit,
+ onSelect: (String, String) -> Unit
+) {
+ val translatorLanguageViewModel: TranslatorLanguageViewModel = hiltViewModel()
+ val mainViewModel: MainViewModel = hiltViewModel()
+
+ val state = translatorLanguageViewModel.state.collectAsState()
+ val mainState = mainViewModel.state.collectAsState()
+
+ val loading = remember { mutableStateOf(true) }
+
+ LaunchedEffect(Unit) {
+ translatorLanguageViewModel.init(
+ selectedLanguage = selectedLanguage,
+ unselectedLanguage = unselectedLanguage,
+ translateFromSelecting = translateFromSelecting,
+ history = mainState.value.translatorLanguageHistory!!,
+ loaded = {
+ loading.value = false
+ }
+ )
+ }
+
+ TranslatorLanguageBottomSheetContent(
+ state = state,
+ loading = loading,
+ onSelect = { from, to ->
+ val fromSelecting = state.value.translateFromSelecting
+ val languageToChoose = if (fromSelecting) from else to
+
+ if (languageToChoose != "auto") {
+ val currentHistory = mainState.value.translatorLanguageHistory!!.toMutableList()
+ val langToAdd = state.value.languageToSelect
+ val newHistory = mutableListOf()
+ val indexOfItem = currentHistory.indexOfFirst { it.languageCode == langToAdd }
+
+ if (indexOfItem != -1) {
+ currentHistory.removeAt(indexOfItem)
+ }
+
+ currentHistory.add(0, LanguageHistory(0, langToAdd))
+
+ currentHistory.sortedBy { it.id }.forEachIndexed { index, languageHistory ->
+ newHistory.add(LanguageHistory(index, languageHistory.languageCode))
+ }
+
+ mainViewModel.onEvent(
+ MainEvent.OnChangeTranslatorLanguageHistory(
+ newHistory
+ )
+ )
+ }
+ onSelect(from, to)
+ },
+ onDismiss = onDismiss,
+ onEvent = translatorLanguageViewModel::onEvent
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun TranslatorLanguageBottomSheetContent(
+ state: State,
+ loading: State,
+ onSelect: (String, String) -> Unit,
+ onDismiss: () -> Unit,
+ onEvent: (TranslatorLanguageEvent) -> Unit
+) {
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+
+ val languagesWithHistory = remember(state.value.languages) {
+ state.value.languages.filter {
+ it.historyOrder != null
+ }.sortedBy {
+ it.historyOrder
+ }
+ }
+ val languages = remember(state.value.languages) {
+ state.value.languages.filter {
+ it.historyOrder == null
+ }.sortedBy {
+ it.languageCode
+ }
+ }
+
+ CustomBottomSheet(
+ hasFixedHeight = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(),
+ dragHandle = {},
+ shape = RoundedCornerShape(0),
+ scrimColor = Color.Transparent,
+ onDismissRequest = {
+ onDismiss()
+ },
+ containerColor = MaterialTheme.colorScheme.surface
+ ) {
+ Scaffold(
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(scrollBehavior.nestedScrollConnection),
+ containerColor = Color.Transparent,
+ topBar = {
+ TranslatorLanguageTopBar(
+ state = state,
+ scrollBehavior = scrollBehavior,
+ onDismissBottomSheet = onDismiss,
+ onEvent = onEvent
+ )
+ }
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ contentAlignment = Alignment.Center
+ ) {
+ DefaultTransition(
+ visible = !loading.value,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ if (state.value.showDownloadLanguageDialog) {
+ TranslatorLanguageDownloadDialog(
+ state = state,
+ onSelect = onSelect,
+ onEvent = onEvent
+ )
+ }
+
+ LazyColumn(Modifier.fillMaxSize()) {
+ item {
+ Spacer(
+ modifier = Modifier
+ .animateItem()
+ .height(12.dp)
+ )
+ }
+
+ if (
+ state.value.translateFromSelecting &&
+ (!state.value.showSearch || state.value.searchQuery.isEmpty())
+ ) {
+ item {
+ val isSelected = remember(state.value.selectedLanguage) {
+ state.value.selectedLanguage == "auto"
+ }
+
+ TranslatorLanguageItemWithIcon(
+ isSelected = isSelected,
+ displayLanguage = stringResource(id = R.string.translator_auto),
+ customIcon = Icons.Default.AutoAwesome,
+ onSelect = {
+ onSelect("auto", state.value.unselectedLanguage)
+ }
+ )
+
+ Spacer(
+ modifier = Modifier
+ .animateItem()
+ .height(36.dp)
+ )
+ }
+ }
+
+ if (languagesWithHistory.isNotEmpty()) {
+ item {
+ CategoryTitle(
+ title = stringResource(id = R.string.translator_recent_languages),
+ color = MaterialTheme.colorScheme.primary,
+ padding = 28.dp,
+ modifier = Modifier.animateItem()
+ )
+ Spacer(
+ modifier = Modifier
+ .animateItem()
+ .height(14.dp)
+ )
+ }
+
+
+ customItems(
+ languagesWithHistory,
+ key = { it.languageCode }
+ ) { language ->
+ TranslatorLanguageItem(
+ language = language,
+ onSelect = onSelect,
+ onEvent = onEvent
+ )
+ }
+ }
+
+ if (languages.isNotEmpty()) {
+ item {
+ if (languagesWithHistory.isNotEmpty()) {
+ Spacer(
+ modifier = Modifier
+ .animateItem()
+ .height(36.dp)
+ )
+ }
+
+ CategoryTitle(
+ title = stringResource(id = R.string.translator_all_languages),
+ color = MaterialTheme.colorScheme.primary,
+ padding = 28.dp,
+ modifier = Modifier.animateItem()
+ )
+ Spacer(
+ modifier = Modifier
+ .animateItem()
+ .height(14.dp)
+ )
+ }
+
+ customItems(
+ languages,
+ key = { it.languageCode }
+ ) { language ->
+ TranslatorLanguageItem(
+ language = language,
+ onSelect = onSelect,
+ onEvent = onEvent
+ )
+ }
+ }
+
+ item {
+ Spacer(
+ modifier = Modifier
+ .animateItem()
+ .height(12.dp)
+ )
+ }
+ }
+
+ CustomAnimatedVisibility(
+ visible = state.value.languages.isEmpty(),
+ modifier = Modifier.align(Alignment.Center),
+ enter = Transitions.DefaultTransitionIn,
+ exit = fadeOut(tween(0))
+ ) {
+ IsEmpty(
+ message = stringResource(id = R.string.translator_languages_empty),
+ icon = painterResource(id = R.drawable.empty_languages)
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageDownloadDialog.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageDownloadDialog.kt
new file mode 100644
index 00000000..2a49a48b
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageDownloadDialog.kt
@@ -0,0 +1,100 @@
+package ua.acclorite.book_story.presentation.components.translator_language.components
+
+import android.widget.Toast
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import ua.acclorite.book_story.R
+import ua.acclorite.book_story.presentation.components.custom_dialog.CustomDialogWithContent
+import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageEvent
+import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageState
+import java.util.Locale
+
+/**
+ * Translator Language Download Dialog.
+ * Used for downloading languages and selecting them right after.
+ *
+ * @param state [TranslatorLanguageState].
+ * @param onSelect OnSelect callback. Happens when language is downloaded.
+ * @param onEvent [TranslatorLanguageEvent] callback.
+ */
+@Composable
+fun TranslatorLanguageDownloadDialog(
+ state: State,
+ onSelect: (String, String) -> Unit,
+ onEvent: (TranslatorLanguageEvent) -> Unit
+) {
+ val context = LocalContext.current
+ val displayLanguage = remember(state.value.languageToSelect) {
+ val locale = Locale(state.value.languageToSelect)
+ locale.getDisplayLanguage(locale).replaceFirstChar {
+ it.uppercase()
+ }
+ }
+
+ CustomDialogWithContent(
+ title = stringResource(id = R.string.download_language, displayLanguage),
+ imageVectorIcon = Icons.Default.Download,
+ description = stringResource(
+ id = R.string.download_language_description
+ ),
+ actionText = stringResource(id = R.string.download),
+ isActionEnabled = true,
+ disableOnClick = true,
+ onDismiss = { onEvent(TranslatorLanguageEvent.OnDismissDownloadDialog) },
+ onAction = {
+ val selectedLanguage = state.value.languageToSelect
+
+ onEvent(TranslatorLanguageEvent.OnDismissDownloadDialog)
+ onEvent(
+ TranslatorLanguageEvent.OnDownloadLanguage(
+ languageCode = state.value.languageToSelect,
+ onComplete = {
+ if (selectedLanguage == state.value.languageToSelect) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.language_downloaded),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+
+ onEvent(
+ TranslatorLanguageEvent.OnSelectLanguage(
+ languageCode = state.value.languageToSelect,
+ onSelect = onSelect,
+ onError = {
+ Toast
+ .makeText(
+ context,
+ it.asString(context),
+ Toast.LENGTH_LONG
+ )
+ .show()
+ }
+ )
+ )
+ }
+ },
+ onFailure = {
+ Toast.makeText(
+ context,
+ context.getString(
+ R.string.error_query,
+ it.message
+ ),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ )
+ )
+ },
+ withDivider = false
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageItem.kt
new file mode 100644
index 00000000..5f1afe22
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageItem.kt
@@ -0,0 +1,243 @@
+package ua.acclorite.book_story.presentation.components.translator_language.components
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyItemScope
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import ua.acclorite.book_story.R
+import ua.acclorite.book_story.domain.model.Language
+import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageEvent
+import ua.acclorite.book_story.presentation.ui.DefaultTransition
+
+/**
+ * Translator Language Item with Icon.
+ *
+ * @param isSelected Whether item is selected.
+ * @param displayLanguage Display language to be shown.
+ * @param customIcon Icon.
+ * @param onSelect OnSelect(click) callback.
+ */
+@Composable
+fun LazyItemScope.TranslatorLanguageItemWithIcon(
+ isSelected: Boolean,
+ displayLanguage: String,
+ customIcon: ImageVector,
+ onSelect: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .animateItem()
+ .padding(horizontal = 12.dp)
+ .clip(RoundedCornerShape(100))
+ .background(
+ if (isSelected) MaterialTheme.colorScheme.primaryContainer
+ else Color.Transparent,
+ RoundedCornerShape(100)
+ )
+ .clickable {
+ onSelect()
+ }
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = stringResource(R.string.selected_content_desc),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ }
+
+ Text(
+ displayLanguage,
+ color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
+ else MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.weight(1f)
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+ Icon(
+ imageVector = customIcon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+}
+
+/**
+ * Translator Language Item with Icon.
+ *
+ * @param language [Language] item.
+ * @param onSelect OnSelect(click) callback.
+ * @param onEvent [TranslatorLanguageEvent] callback.
+ */
+@Composable
+fun LazyItemScope.TranslatorLanguageItem(
+ language: Language,
+ onSelect: (String, String) -> Unit,
+ onEvent: (TranslatorLanguageEvent) -> Unit
+) {
+ val context = LocalContext.current
+
+ Row(
+ modifier = Modifier
+ .animateItem()
+ .padding(horizontal = 12.dp)
+ .clip(RoundedCornerShape(100))
+ .background(
+ if (language.isSelected) MaterialTheme.colorScheme.primaryContainer
+ else Color.Transparent,
+ RoundedCornerShape(100)
+ )
+ .clickable(enabled = !language.isDownloading) {
+ onEvent(
+ TranslatorLanguageEvent.OnSelectLanguage(
+ languageCode = language.languageCode,
+ onSelect = onSelect,
+ onError = {
+ Toast
+ .makeText(
+ context,
+ it.asString(context),
+ Toast.LENGTH_LONG
+ )
+ .show()
+ }
+ )
+ )
+ }
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (language.isSelected) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = stringResource(R.string.selected_content_desc),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ }
+
+ Text(
+ language.displayLanguage,
+ color = if (language.isSelected) MaterialTheme.colorScheme.onPrimaryContainer
+ else MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.weight(1f)
+ )
+
+ Box(contentAlignment = Alignment.CenterEnd) {
+ DefaultTransition(!language.isDownloaded && !language.isDownloading) {
+ Row {
+ Spacer(modifier = Modifier.width(16.dp))
+ Icon(
+ imageVector = Icons.Default.Download,
+ contentDescription = stringResource(id = R.string.download),
+ tint = if (language.isSelected) MaterialTheme.colorScheme.onSurfaceVariant
+ else MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .size(24.dp)
+ .clickable(
+ interactionSource = null,
+ indication = null
+ ) {
+ onEvent(
+ TranslatorLanguageEvent.OnDownloadLanguage(
+ languageCode = language.languageCode,
+ onComplete = {},
+ onFailure = {
+ Toast
+ .makeText(
+ context,
+ context.getString(
+ R.string.error_query,
+ it.message
+ ),
+ Toast.LENGTH_LONG
+ )
+ .show()
+ }
+ )
+ )
+ }
+ )
+ }
+ }
+
+ DefaultTransition(language.isDownloading && !language.isDownloaded) {
+ Row {
+ Spacer(modifier = Modifier.width(16.dp))
+ CircularProgressIndicator(
+ strokeWidth = 2.7.dp,
+ modifier = Modifier
+ .size(22.dp)
+ .clickable(
+ interactionSource = null,
+ indication = null
+ ) {
+ onEvent(
+ TranslatorLanguageEvent.OnCancelDownload(
+ languageCode = language.languageCode
+ )
+ )
+ },
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.width(1.dp))
+ }
+ }
+
+ DefaultTransition(language.isDownloaded && !language.isDownloading) {
+ Row {
+ Spacer(modifier = Modifier.width(16.dp))
+ Icon(
+ imageVector = Icons.Filled.CheckCircle,
+ contentDescription = stringResource(id = R.string.done),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .size(24.dp)
+ .clickable(
+ interactionSource = null,
+ indication = null
+ ) {
+ onEvent(
+ TranslatorLanguageEvent.OnDeleteLanguage(
+ languageCode = language.languageCode
+ )
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageTopBar.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageTopBar.kt
new file mode 100644
index 00000000..3274cbec
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageTopBar.kt
@@ -0,0 +1,107 @@
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package ua.acclorite.book_story.presentation.components.translator_language.components
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.res.stringResource
+import ua.acclorite.book_story.R
+import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar
+import ua.acclorite.book_story.presentation.components.CustomIconButton
+import ua.acclorite.book_story.presentation.components.CustomSearchTextField
+import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageEvent
+import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageState
+
+/**
+ * Translator Language TopBar.
+ *
+ * @param state [TranslatorLanguageState].
+ * @param scrollBehavior [TopAppBarScrollBehavior].
+ * @param onDismissBottomSheet OnDismiss callback.
+ * @param onEvent [TranslatorLanguageEvent] callback.
+ */
+@Composable
+fun TranslatorLanguageTopBar(
+ state: State,
+ scrollBehavior: TopAppBarScrollBehavior,
+ onDismissBottomSheet: () -> Unit,
+ onEvent: (TranslatorLanguageEvent) -> Unit
+) {
+ val focusRequester = remember { FocusRequester() }
+
+ AnimatedTopAppBar(
+ scrollBehavior = scrollBehavior,
+ isTopBarScrolled = null,
+
+ content1Visibility = !state.value.showSearch,
+ content1NavigationIcon = {
+ CustomIconButton(
+ icon = Icons.Default.Close,
+ contentDescription = R.string.go_back_content_desc,
+ disableOnClick = true
+ ) {
+ onDismissBottomSheet()
+ }
+ },
+ content1Title = {
+ Text(
+ text = stringResource(
+ id = R.string.translator_query, stringResource(
+ if (state.value.translateFromSelecting) R.string.translator_from
+ else R.string.translator_to
+ )
+ )
+ )
+ },
+ content1Actions = {
+ CustomIconButton(
+ icon = Icons.Default.Search,
+ contentDescription = R.string.search_content_desc,
+ disableOnClick = true
+ ) {
+ onEvent(TranslatorLanguageEvent.OnSearchShowHide)
+ }
+ },
+
+ content2Visibility = state.value.showSearch,
+ content2NavigationIcon = {
+ CustomIconButton(
+ icon = Icons.AutoMirrored.Default.ArrowBack,
+ contentDescription = R.string.exit_search_content_desc,
+ disableOnClick = true
+ ) {
+ onEvent(TranslatorLanguageEvent.OnSearchShowHide)
+ }
+ },
+ content2Title = {
+ CustomSearchTextField(
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .onGloballyPositioned {
+ onEvent(TranslatorLanguageEvent.OnRequestFocus(focusRequester))
+ },
+ query = state.value.searchQuery,
+ onQueryChange = {
+ onEvent(TranslatorLanguageEvent.OnSearchQueryChange(it))
+ },
+ onSearch = { onEvent(TranslatorLanguageEvent.OnSearch) },
+ placeholder = stringResource(
+ id = R.string.search_query,
+ stringResource(id = R.string.languages)
+ )
+ )
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageEvent.kt
new file mode 100644
index 00000000..dc851296
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageEvent.kt
@@ -0,0 +1,39 @@
+package ua.acclorite.book_story.presentation.components.translator_language.data
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.focus.FocusRequester
+import ua.acclorite.book_story.domain.model.Language
+import ua.acclorite.book_story.domain.util.UIText
+
+@Immutable
+sealed class TranslatorLanguageEvent {
+ data class OnSelectLanguage(
+ val languageCode: String,
+ val onError: suspend (UIText) -> Unit,
+ val onSelect: (String, String) -> Unit
+ ) : TranslatorLanguageEvent()
+
+ data class OnDownloadLanguage(
+ val languageCode: String,
+ val onComplete: suspend () -> Unit,
+ val onFailure: (Exception) -> Unit
+ ) : TranslatorLanguageEvent()
+
+ data class OnCancelDownload(
+ val languageCode: String
+ ) : TranslatorLanguageEvent()
+
+ data class OnDeleteLanguage(
+ val languageCode: String
+ ) : TranslatorLanguageEvent()
+
+ data object OnSearchShowHide : TranslatorLanguageEvent()
+ data class OnRequestFocus(val focusRequester: FocusRequester) : TranslatorLanguageEvent()
+ data class OnSearchQueryChange(val query: String) : TranslatorLanguageEvent()
+ data object OnSearch : TranslatorLanguageEvent()
+ data object OnDismissDownloadDialog : TranslatorLanguageEvent()
+ data class OnUpdateLanguage(
+ val languageCode: String,
+ val block: (Language) -> Language
+ ) : TranslatorLanguageEvent()
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageState.kt
new file mode 100644
index 00000000..0ffc0005
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageState.kt
@@ -0,0 +1,21 @@
+package ua.acclorite.book_story.presentation.components.translator_language.data
+
+import androidx.compose.runtime.Immutable
+import ua.acclorite.book_story.domain.model.Language
+
+@Immutable
+data class TranslatorLanguageState(
+ val selectedLanguage: String = "",
+ val unselectedLanguage: String = "",
+ val languageToSelect: String = "",
+ val translateFromSelecting: Boolean = false,
+ val showDownloadLanguageDialog: Boolean = false,
+
+ val unfilteredLanguages: List = emptyList(),
+ val languages: List = emptyList(),
+
+ val showSearch: Boolean = false,
+ val searchQuery: String = "",
+ val hasFocused: Boolean = false,
+
+ )
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageViewModel.kt
new file mode 100644
index 00000000..c3c07c51
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageViewModel.kt
@@ -0,0 +1,424 @@
+package ua.acclorite.book_story.presentation.components.translator_language.data
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.mlkit.nl.translate.TranslateLanguage
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.yield
+import ua.acclorite.book_story.R
+import ua.acclorite.book_story.domain.model.Language
+import ua.acclorite.book_story.domain.model.LanguageHistory
+import ua.acclorite.book_story.domain.use_case.DeleteLanguageModel
+import ua.acclorite.book_story.domain.use_case.DownloadLanguageModel
+import ua.acclorite.book_story.domain.use_case.IsLanguageModelDownloaded
+import ua.acclorite.book_story.domain.util.UIText
+import java.util.Locale
+import javax.inject.Inject
+
+@HiltViewModel
+class TranslatorLanguageViewModel @Inject constructor(
+ private val isLanguageModelDownloaded: IsLanguageModelDownloaded,
+ private val downloadLanguageModel: DownloadLanguageModel,
+ private val deleteLanguageModel: DeleteLanguageModel
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(TranslatorLanguageState())
+ val state = _state.asStateFlow()
+
+ private var job: Job? = null
+
+ @Suppress("LABEL_NAME_CLASH")
+ fun onEvent(event: TranslatorLanguageEvent) {
+ when (event) {
+ is TranslatorLanguageEvent.OnSelectLanguage -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ if (_state.value.showDownloadLanguageDialog) {
+ return@launch
+ }
+
+ val isDownloaded = isLanguageModelDownloaded.execute(
+ event.languageCode
+ )
+
+ _state.update {
+ it.copy(
+ languageToSelect = event.languageCode
+ )
+ }
+
+ onEvent(
+ TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) {
+ it.copy(
+ isDownloaded = isDownloaded
+ )
+ }
+ )
+
+ if (isDownloaded) {
+ val from = _state.value.translateFromSelecting
+ val langToSelect = _state.value.languageToSelect
+ val unselectedLang = _state.value.unselectedLanguage
+ val selectedLang = _state.value.selectedLanguage
+
+ val translateFrom: String
+ val translateTo: String
+
+ if (langToSelect == unselectedLang) {
+ if (from) {
+ if (selectedLang == "auto") {
+ launch(Dispatchers.Main) {
+ event.onError(
+ UIText.StringResource(R.string.error_cannot_select_language)
+ )
+ }
+ return@launch
+ }
+
+ translateFrom = langToSelect
+ translateTo = selectedLang
+ } else {
+ translateTo = langToSelect
+ translateFrom = selectedLang
+ }
+ } else {
+ if (from) {
+ translateFrom = langToSelect
+ translateTo = unselectedLang
+ } else {
+ translateTo = langToSelect
+ translateFrom = unselectedLang
+ }
+ }
+
+ if (translateTo == "auto") {
+ launch(Dispatchers.Main) {
+ event.onError(
+ UIText.StringResource(R.string.error_wrong_language)
+ )
+ }
+ return@launch
+ }
+
+ event.onSelect(
+ translateFrom,
+ translateTo
+ )
+ return@launch
+ }
+
+ _state.update {
+ it.copy(
+ showDownloadLanguageDialog = true
+ )
+ }
+ }
+ }
+
+ is TranslatorLanguageEvent.OnDownloadLanguage -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ onEvent(
+ TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) {
+ it.copy(
+ isDownloading = true,
+ isCanceled = false
+ )
+ }
+ )
+
+ val isDownloaded = isLanguageModelDownloaded.execute(
+ event.languageCode
+ )
+
+ onEvent(
+ TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) {
+ it.copy(
+ isDownloaded = isDownloaded
+ )
+ }
+ )
+
+ if (isDownloaded) {
+ onEvent(
+ TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) {
+ it.copy(
+ isDownloading = false
+ )
+ }
+ )
+ return@launch
+ }
+
+ downloadLanguageModel.execute(
+ languageCode = event.languageCode,
+ onCompleted = {
+ viewModelScope.launch(Dispatchers.IO) {
+ val modelDownloaded = isLanguageModelDownloaded.execute(
+ event.languageCode
+ )
+
+ onEvent(
+ TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) {
+ it.copy(
+ isDownloading = false,
+ isDownloaded = modelDownloaded
+ )
+ }
+ )
+
+ val isCanceled = _state.value.unfilteredLanguages.find {
+ it.languageCode == event.languageCode
+ }?.isCanceled
+
+ if (isCanceled == true && modelDownloaded) {
+ deleteLanguageModel.execute(
+ languageCode = event.languageCode
+ )
+ onEvent(
+ TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) {
+ it.copy(
+ isDownloading = false,
+ isDownloaded = false,
+ isCanceled = false
+ )
+ }
+ )
+ return@launch
+ }
+
+ event.onComplete()
+ }
+ },
+ onFailure = {
+ event.onFailure(it)
+ }
+ )
+ }
+ }
+
+ is TranslatorLanguageEvent.OnCancelDownload -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ onEvent(
+ TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) {
+ it.copy(
+ isDownloaded = false,
+ isDownloading = false,
+ isCanceled = true
+ )
+ }
+ )
+ }
+ }
+
+ is TranslatorLanguageEvent.OnDeleteLanguage -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ val deleted = deleteLanguageModel.execute(event.languageCode)
+
+ if (deleted) {
+ onEvent(
+ TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) {
+ it.copy(
+ isDownloaded = false,
+ isDownloading = false
+ )
+ }
+ )
+ }
+ }
+ }
+
+ is TranslatorLanguageEvent.OnSearchShowHide -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ val shouldHide = _state.value.showSearch
+
+ if (shouldHide) {
+ filterLanguages("")
+ } else {
+ _state.update {
+ it.copy(
+ searchQuery = "",
+ hasFocused = false
+ )
+ }
+ }
+
+ _state.update {
+ it.copy(
+ showSearch = !shouldHide
+ )
+ }
+ }
+ }
+
+ is TranslatorLanguageEvent.OnSearchQueryChange -> {
+ _state.update {
+ it.copy(
+ searchQuery = event.query
+ )
+ }
+ job?.cancel()
+ job = viewModelScope.launch(Dispatchers.IO) {
+ delay(500)
+ yield()
+ onEvent(TranslatorLanguageEvent.OnSearch)
+ }
+ }
+
+ is TranslatorLanguageEvent.OnSearch -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ filterLanguages()
+ }
+ }
+
+ is TranslatorLanguageEvent.OnRequestFocus -> {
+ if (!_state.value.hasFocused) {
+ event.focusRequester.requestFocus()
+ _state.update {
+ it.copy(
+ hasFocused = true
+ )
+ }
+ }
+ }
+
+ is TranslatorLanguageEvent.OnDismissDownloadDialog -> {
+ _state.update {
+ it.copy(
+ showDownloadLanguageDialog = false
+ )
+ }
+ }
+
+ is TranslatorLanguageEvent.OnUpdateLanguage -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ val unfilteredLanguages = _state.value.unfilteredLanguages.map {
+ if (it.languageCode == event.languageCode) {
+ event.block(it)
+ } else {
+ it
+ }
+ }
+
+ _state.update {
+ it.copy(
+ unfilteredLanguages = unfilteredLanguages
+ )
+ }
+ filterLanguages()
+ }
+ }
+ }
+ }
+
+ fun init(
+ selectedLanguage: String,
+ unselectedLanguage: String,
+ translateFromSelecting: Boolean,
+ history: List,
+ loaded: () -> Unit
+ ) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val oldState = _state.value
+ _state.update {
+ TranslatorLanguageState()
+ }
+
+ val unfilteredLanguages = TranslateLanguage.getAllLanguages()
+ .filter {
+ if (it == "ru") {
+ return@filter false
+ }
+
+ true
+ }
+ .map { languageCode ->
+ val previousLanguage = oldState.languages.find {
+ it.languageCode == languageCode
+ }
+
+ val locale = Locale(languageCode)
+ val displayLanguage = locale.getDisplayLanguage(locale).replaceFirstChar {
+ it.uppercase()
+ }
+
+ val isDownloading = previousLanguage?.isDownloading ?: false
+ val isCancelled = previousLanguage?.isCanceled ?: false
+ val isSelected = selectedLanguage == languageCode
+
+ val historyOrder = history.find {
+ it.languageCode == languageCode
+ }?.id
+
+ Language(
+ languageCode = languageCode,
+ displayLanguage = displayLanguage,
+ isDownloading = isDownloading,
+ isDownloaded = false,
+ isCanceled = isCancelled,
+ isSelected = isSelected,
+ historyOrder = historyOrder
+ )
+ }
+
+
+ _state.update {
+ it.copy(
+ selectedLanguage = selectedLanguage,
+ unselectedLanguage = unselectedLanguage,
+ translateFromSelecting = translateFromSelecting,
+ unfilteredLanguages = unfilteredLanguages
+ )
+ }
+ filterLanguages()
+ loaded()
+ loadDownloadedState()
+ }
+ }
+
+ private suspend fun loadDownloadedState() {
+ viewModelScope.launch(Dispatchers.IO) {
+ _state.value.unfilteredLanguages.map { language ->
+ val isDownloaded = isLanguageModelDownloaded.execute(
+ language.languageCode
+ )
+ val isDownloading = if (isDownloaded) false else null
+
+ onEvent(
+ TranslatorLanguageEvent.OnUpdateLanguage(
+ languageCode = language.languageCode,
+ block = {
+ it.copy(
+ isDownloaded = isDownloaded,
+ isDownloading = isDownloading ?: it.isDownloading
+ )
+ }
+ )
+ )
+ }
+ }
+ }
+
+ private fun filterLanguages(
+ query: String = if (_state.value.showSearch) _state.value.searchQuery else ""
+ ) {
+ _state.update {
+ it.copy(
+ languages = it.unfilteredLanguages.filter { language ->
+ if (query.isBlank()) {
+ return@filter true
+ }
+
+ language.displayLanguage.lowercase().trim().contains(
+ query.lowercase().trim()
+ )
+ }
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/data/ArithmeticOperations.kt b/app/src/main/java/ua/acclorite/book_story/presentation/data/ArithmeticOperations.kt
index 1759bdd2..91d8f848 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/data/ArithmeticOperations.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/data/ArithmeticOperations.kt
@@ -10,3 +10,61 @@ fun String.removeTrailingZero(): String {
fun Double.removeDigits(digits: Int) = "%.${digits}f".format(this).replace(",", ".")
+fun Map.update(item: Pair): Map {
+ val keyExists = containsKey(item.first)
+
+ return if (!keyExists) this
+ else this.plus(item)
+}
+
+fun Map.update(items: List>): Map {
+ var map = this
+
+ for (item in items) {
+ map = map.update(item)
+ }
+
+ return map
+}
+
+fun Map.update(items: Map): Map {
+ var map = this
+
+ for (item in items) {
+ map = map.update(item.toPair())
+ }
+
+ return map
+}
+
+fun Map.updateWithCopy(
+ calculation: (T) -> T
+): Map {
+ var map = this
+
+ for (item in this) {
+ map = map.update(item.key to calculation(item.value))
+ }
+
+ return map
+}
+
+fun Map.removeKeys(
+ keys: Set
+): Map {
+ val map = this.toMutableMap()
+
+ for (key in keys) {
+ map.remove(key)
+ }
+
+ return map.toMap()
+}
+
+fun Map.removeKey(
+ key: K
+): Map {
+ val map = this.toMutableMap()
+ map.remove(key)
+ return map.toMap()
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainEvent.kt
index 50bd5c42..0f92ccbf 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainEvent.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainEvent.kt
@@ -1,5 +1,7 @@
package ua.acclorite.book_story.presentation.data
+import ua.acclorite.book_story.domain.model.LanguageHistory
+
sealed class MainEvent {
data class OnChangeLanguage(val lang: String) : MainEvent()
data class OnChangeTheme(val theme: String) : MainEvent()
@@ -17,4 +19,9 @@ sealed class MainEvent {
data class OnChangeShowStartScreen(val bool: Boolean) : MainEvent()
data class OnChangeCheckForUpdates(val bool: Boolean) : MainEvent()
data class OnChangeSidePadding(val sidePadding: Int) : MainEvent()
+ data class OnChangeEnableTranslator(val bool: Boolean) : MainEvent()
+ data class OnChangeTranslateFrom(val languageCode: String) : MainEvent()
+ data class OnChangeTranslateTo(val languageCode: String) : MainEvent()
+ data class OnChangeDoubleClickTranslation(val bool: Boolean) : MainEvent()
+ data class OnChangeTranslatorLanguageHistory(val history: List) : MainEvent()
}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainState.kt
index 875f040e..fb5602cd 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainState.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainState.kt
@@ -5,6 +5,12 @@ import android.os.Parcel
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
+import com.google.mlkit.nl.translate.TranslateLanguage
+import com.squareup.moshi.JsonAdapter
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.Types
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import ua.acclorite.book_story.domain.model.LanguageHistory
import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.domain.util.DataStoreConstants
import ua.acclorite.book_story.presentation.ui.DarkTheme
@@ -17,8 +23,19 @@ import ua.acclorite.book_story.presentation.ui.toTheme
import ua.acclorite.book_story.presentation.ui.toThemeContrast
import java.util.Locale
+val translatorHistoryMoshi: JsonAdapter> = Moshi
+ .Builder()
+ .add(KotlinJsonAdapterFactory())
+ .build()
+ .adapter(
+ Types.newParameterizedType(
+ List::class.java,
+ LanguageHistory::class.java
+ )
+ )
+
/**
- * Main State. All app's settings are here. Wrapped in SavedStateHandle, so it won't reset.
+ * Main State. All app's settings/preferences/permanent-variables are here. Wrapped in SavedStateHandle, so it won't reset.
*/
@Immutable
data class MainState(
@@ -38,7 +55,13 @@ data class MainState(
val showStartScreen: Boolean? = null,
val checkForUpdates: Boolean? = null,
val sidePadding: Int? = null,
+ val enableTranslator: Boolean? = null,
+ val translateFrom: String? = null,
+ val translateTo: String? = null,
+ val doubleClickTranslation: Boolean? = null,
+ val translatorLanguageHistory: List? = null,
) : Parcelable {
+
constructor(parcel: Parcel) : this(
// String
language = parcel.readString(),
@@ -75,6 +98,19 @@ data class MainState(
.toInt() == 1 else null,
// Int
sidePadding = if (parcel.readByte().toInt() != 0) parcel.readInt() else null,
+ // Boolean
+ enableTranslator = if (parcel.readByte().toInt() != 0) parcel.readByte()
+ .toInt() == 1 else null,
+ // String
+ translateFrom = parcel.readString(),
+ // String
+ translateTo = parcel.readString(),
+ // Boolean
+ doubleClickTranslation = if (parcel.readByte().toInt() != 0) parcel.readByte()
+ .toInt() == 1 else null,
+ translatorLanguageHistory = parcel.readString()?.let {
+ translatorHistoryMoshi.fromJson(it)
+ }
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
@@ -117,9 +153,21 @@ data class MainState(
// Check For Updates
parcel.writeByte((if (checkForUpdates != null) 1 else 0).toByte())
checkForUpdates?.let { parcel.writeByte(if (it) 1 else 0) }
- // Side padding
+ // Side Padding
parcel.writeByte((if (sidePadding != null) 1 else 0).toByte())
sidePadding?.let { parcel.writeInt(it) }
+ // Enable Translator
+ parcel.writeByte((if (enableTranslator != null) 1 else 0).toByte())
+ enableTranslator?.let { parcel.writeByte(if (it) 1 else 0) }
+ // Translate From
+ parcel.writeString(translateFrom)
+ // Translate To
+ parcel.writeString(translateTo)
+ // Double Click Translation
+ parcel.writeByte((if (doubleClickTranslation != null) 1 else 0).toByte())
+ doubleClickTranslation?.let { parcel.writeByte(if (it) 1 else 0) }
+ // Translator Language History
+ parcel.writeString(translatorHistoryMoshi.toJson(translatorLanguageHistory))
}
override fun describeContents(): Int {
@@ -140,7 +188,6 @@ data class MainState(
*/
fun initialize(data: Map): MainState {
DataStoreConstants.apply {
-
val language: String = data[LANGUAGE.name] as? String ?: if (
Constants.LANGUAGES.any { Locale.getDefault().language.take(2) == it.first }
) {
@@ -195,6 +242,30 @@ data class MainState(
val sidePadding: Int = data[SIDE_PADDING.name] as? Int
?: 6
+ val enableTranslator: Boolean = data[ENABLE_TRANSLATOR.name] as? Boolean
+ ?: false
+
+ val translateFrom: String = data[TRANSLATE_FROM.name] as? String
+ ?: "auto"
+
+ val translateTo: String = data[TRANSLATE_TO.name] as? String ?: if (
+ TranslateLanguage.getAllLanguages().any { it == language }
+ ) {
+ language
+ } else {
+ "en"
+ }
+
+ val doubleClickTranslation: Boolean =
+ data[DOUBLE_CLICK_TRANSLATION.name] as? Boolean
+ ?: true
+
+ val translatorLanguageHistory: List =
+ (data[TRANSLATOR_LANGUAGE_HISTORY.name] as? String)?.let {
+ translatorHistoryMoshi.fromJson(it)
+ } ?: emptyList()
+
+
return MainState(
language = language,
theme = theme.toTheme(),
@@ -211,7 +282,12 @@ data class MainState(
paragraphHeight = paragraphHeight,
paragraphIndentation = paragraphIndentation,
checkForUpdates = checkForUpdates,
- sidePadding = sidePadding
+ sidePadding = sidePadding,
+ enableTranslator = enableTranslator,
+ translateFrom = translateFrom,
+ translateTo = translateTo,
+ doubleClickTranslation = doubleClickTranslation,
+ translatorLanguageHistory = translatorLanguageHistory
)
}
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt
index e5664e58..07cbbd4b 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt
@@ -227,6 +227,66 @@ class MainViewModel @Inject constructor(
}
}
}
+
+ is MainEvent.OnChangeEnableTranslator -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ setDatastore.execute(DataStoreConstants.ENABLE_TRANSLATOR, event.bool)
+ updateStateWithSavedHandle {
+ it.copy(
+ enableTranslator = event.bool
+ )
+ }
+ }
+ }
+
+ is MainEvent.OnChangeTranslateFrom -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ setDatastore.execute(DataStoreConstants.TRANSLATE_FROM, event.languageCode)
+ updateStateWithSavedHandle {
+ it.copy(
+ translateFrom = event.languageCode
+ )
+ }
+ }
+ }
+
+ is MainEvent.OnChangeTranslateTo -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ setDatastore.execute(DataStoreConstants.TRANSLATE_TO, event.languageCode)
+ updateStateWithSavedHandle {
+ it.copy(
+ translateTo = event.languageCode
+ )
+ }
+ }
+ }
+
+ is MainEvent.OnChangeDoubleClickTranslation -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ setDatastore.execute(DataStoreConstants.DOUBLE_CLICK_TRANSLATION, event.bool)
+ updateStateWithSavedHandle {
+ it.copy(
+ doubleClickTranslation = event.bool
+ )
+ }
+ }
+ }
+
+ is MainEvent.OnChangeTranslatorLanguageHistory -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ val historyJson = translatorHistoryMoshi.toJson(event.history)
+
+ setDatastore.execute(
+ DataStoreConstants.TRANSLATOR_LANGUAGE_HISTORY,
+ historyJson
+ )
+ updateStateWithSavedHandle {
+ it.copy(
+ translatorLanguageHistory = event.history
+ )
+ }
+ }
+ }
}
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/data/AboutState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/data/AboutState.kt
index c3f9b35e..3c32b2e4 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/data/AboutState.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/data/AboutState.kt
@@ -1,10 +1,10 @@
package ua.acclorite.book_story.presentation.screens.about.data
import androidx.compose.runtime.Immutable
-import ua.acclorite.book_story.data.remote.dto.ReleaseResponse
+import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo
@Immutable
data class AboutState(
val showUpdateDialog: Boolean = false,
- val updateInfo: ReleaseResponse? = null
+ val updateInfo: LatestReleaseInfo? = null
)
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/CreditsScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/CreditsScreen.kt
index 945f6533..030fcd00 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/CreditsScreen.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/CreditsScreen.kt
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
@@ -24,6 +23,7 @@ import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.presentation.components.GoBackButton
import ua.acclorite.book_story.presentation.components.collapsibleUntilExitScrollBehaviorWithLazyListState
+import ua.acclorite.book_story.presentation.components.customItems
import ua.acclorite.book_story.presentation.data.LocalNavigator
import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.screens.about.data.AboutEvent
@@ -78,7 +78,7 @@ private fun CreditsScreen(
.padding(top = paddingValues.calculateTopPadding()),
state = scrollState.second
) {
- items(Constants.CREDITS, key = { it.name }) {
+ customItems(Constants.CREDITS, key = { it.name }) {
CreditItem(credit = it) {
onAboutNavigateEvent(
AboutEvent.OnNavigateToBrowserPage(
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/components/CreditItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/components/CreditItem.kt
index 03d97029..98257077 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/components/CreditItem.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/components/CreditItem.kt
@@ -23,6 +23,11 @@ import ua.acclorite.book_story.domain.model.Credit
/**
* Credit Item.
+ *
+ * @param credit [Credit] item.
+ * @param horizontalPadding Horizontal item padding.
+ * @param verticalPadding Vertical item padding.
+ * @param onClick OnClick callback.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -67,7 +72,7 @@ fun CreditItem(
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.clip(RoundedCornerShape(100))
- .background(MaterialTheme.colorScheme.primary)
+ .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(100))
.padding(vertical = 4.dp, horizontal = 8.dp)
)
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/license_info/LicenseInfoScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/license_info/LicenseInfoScreen.kt
index c156d828..580a3ace 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/license_info/LicenseInfoScreen.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/license_info/LicenseInfoScreen.kt
@@ -183,7 +183,10 @@ private fun LicenseInfoScreen(
.clickable(enabled = license.licenseContent?.isNotBlank() == true) {
showed.value = !showed.value
}
- .background(MaterialTheme.colorScheme.primary)
+ .background(
+ MaterialTheme.colorScheme.primary,
+ RoundedCornerShape(100)
+ )
.padding(vertical = 6.dp, horizontal = 12.dp)
)
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/licenses/components/LicenseItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/licenses/components/LicenseItem.kt
index 30761762..d62603b3 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/licenses/components/LicenseItem.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/licenses/components/LicenseItem.kt
@@ -27,6 +27,11 @@ import com.mikepenz.aboutlibraries.ui.compose.m3.util.author
/**
* License Item.
+ *
+ * @param modifier Modifier.
+ * @param library [Library] item.
+ * @param horizontalPadding Horizontal item padding.
+ * @param verticalPadding Vertical item padding.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -85,7 +90,7 @@ fun LicenseItem(
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.clip(RoundedCornerShape(100))
- .background(MaterialTheme.colorScheme.primary)
+ .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(100))
.padding(vertical = 4.dp, horizontal = 8.dp)
)
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/BookInfoInfoSection.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/BookInfoInfoSection.kt
index 3b5193ae..082fa423 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/BookInfoInfoSection.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/BookInfoInfoSection.kt
@@ -45,7 +45,10 @@ import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoState
/**
- * Info section.
+ * BookInfo's Info section.
+ *
+ * @param state [BookInfoState] instance.
+ * @param onEvent [BookInfoEvent] callback.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -65,7 +68,10 @@ fun BookInfoInfoSection(
.height(195.dp)
.width(130.dp)
.clip(MaterialTheme.shapes.medium)
- .background(MaterialTheme.colorScheme.surfaceContainerLow)
+ .background(
+ MaterialTheme.colorScheme.surfaceContainerLow,
+ MaterialTheme.shapes.medium
+ )
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/change_cover_bottom_sheet/BookInfoChangeCoverBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/change_cover_bottom_sheet/BookInfoChangeCoverBottomSheet.kt
index ec6bde27..7c9251f8 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/change_cover_bottom_sheet/BookInfoChangeCoverBottomSheet.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/change_cover_bottom_sheet/BookInfoChangeCoverBottomSheet.kt
@@ -5,18 +5,11 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.navigationBars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HideImage
import androidx.compose.material.icons.filled.ImageSearch
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
@@ -24,6 +17,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
+import ua.acclorite.book_story.presentation.components.CustomBottomSheet
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoState
import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent
@@ -31,8 +25,12 @@ import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
/**
* Change cover bottom sheet. Lets user select photo from the gallery and replaces old one.
+ *
+ * @param state [BookInfoState] instance.
+ * @param onEvent [BookInfoEvent] callback.
+ * @param onLibraryUpdateEvent [LibraryEvent] callback.
+ * @param onHistoryUpdateEvent [HistoryEvent] callback.
*/
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookInfoChangeCoverBottomSheet(
state: State,
@@ -41,8 +39,6 @@ fun BookInfoChangeCoverBottomSheet(
onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit
) {
val context = LocalContext.current
- val navigationBarPadding =
- WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val photoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri ->
@@ -66,16 +62,12 @@ fun BookInfoChangeCoverBottomSheet(
}
)
- ModalBottomSheet(
+ CustomBottomSheet(
modifier = Modifier.fillMaxWidth(),
onDismissRequest = {
onEvent(BookInfoEvent.OnShowHideChangeCoverBottomSheet)
- },
- sheetState = rememberModalBottomSheetState(true),
- contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow
- ) {
-
+ }
+ ) { padding ->
BookInfoChangeCoverBottomSheetItem(
icon = Icons.Default.ImageSearch,
text = stringResource(id = R.string.change_cover),
@@ -110,7 +102,7 @@ fun BookInfoChangeCoverBottomSheet(
Spacer(
modifier = Modifier.height(
- 8.dp + navigationBarPadding
+ 8.dp + padding
)
)
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/details_bottom_sheet/BookInfoDetailsBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/details_bottom_sheet/BookInfoDetailsBottomSheet.kt
index c8200c95..eaeb57cf 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/details_bottom_sheet/BookInfoDetailsBottomSheet.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/details_bottom_sheet/BookInfoDetailsBottomSheet.kt
@@ -2,15 +2,8 @@ package ua.acclorite.book_story.presentation.screens.book_info.components.detail
import android.widget.Toast
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.navigationBars
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
@@ -19,6 +12,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
+import ua.acclorite.book_story.presentation.components.CustomBottomSheet
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoState
import java.io.File
@@ -28,16 +22,16 @@ import java.util.Locale
/**
* Details bottom sheet. Displays name, path, last opened and size of the file.
+ *
+ * @param state [BookInfoState] instance.
+ * @param onEvent [BookInfoEvent] callback.
*/
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookInfoDetailsBottomSheet(
state: State,
onEvent: (BookInfoEvent) -> Unit
) {
val context = LocalContext.current
- val navigationBarPadding =
- WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val pattern = remember {
SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault())
@@ -66,14 +60,11 @@ fun BookInfoDetailsBottomSheet(
else ""
}
- ModalBottomSheet(
+ CustomBottomSheet(
modifier = Modifier.fillMaxWidth(),
onDismissRequest = {
onEvent(BookInfoEvent.OnShowHideDetailsBottomSheet)
- },
- sheetState = rememberModalBottomSheetState(true),
- contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow
+ }
) {
BookInfoDetailsBottomSheetItem(
title = stringResource(id = R.string.file_name),
@@ -151,7 +142,7 @@ fun BookInfoDetailsBottomSheet(
Spacer(
modifier = Modifier.height(
- 8.dp + navigationBarPadding
+ 8.dp + it
)
)
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoEvent.kt
index 95b5ee49..31017070 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoEvent.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoEvent.kt
@@ -7,7 +7,6 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.focus.FocusRequester
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category
-import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.presentation.data.Navigator
@Immutable
@@ -58,7 +57,7 @@ sealed class BookInfoEvent {
data object OnDismissConfirmUpdateDialog : BookInfoEvent()
data class OnShowConfirmUpdateDialog(
- val updatedBook: Pair>,
+ val updatedBook: Pair>,
val authorUpdated: Boolean,
val descriptionUpdated: Boolean,
val textUpdated: Boolean,
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoState.kt
index 1f878d59..9a96949e 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoState.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoState.kt
@@ -3,7 +3,6 @@ package ua.acclorite.book_story.presentation.screens.book_info.data
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category
-import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.Constants
@Immutable
@@ -13,7 +12,7 @@ data class BookInfoState(
val isLoadingUpdate: Boolean = false,
val isRefreshing: Boolean = false,
val showConfirmUpdateDialog: Boolean = false,
- val updatedBook: Pair>? = null,
+ val updatedBook: Pair>? = null,
val authorChanged: Boolean = false,
val descriptionChanged: Boolean = false,
val textChanged: Boolean = false,
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoViewModel.kt
index 4abf5ad6..11de879a 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoViewModel.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoViewModel.kt
@@ -386,7 +386,7 @@ class BookInfoViewModel @Inject constructor(
val updatedText = updatedBook.text
val text = getText.execute(book.textPath)
- if (updatedText.map { it.line } != text.map { it.line }) {
+ if (updatedText != text.map { it.value.originalLine }) {
textUpdated = true
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseFIleItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseFIleItem.kt
index 254bd113..b9936f55 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseFIleItem.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseFIleItem.kt
@@ -22,6 +22,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -41,6 +42,12 @@ import java.util.Locale
/**
* Browse list element item. Can be selected.
+ *
+ * @param file [File].
+ * @param hasSelectedFiles Whether parent list has selected items.
+ * @param modifier Modifier.
+ * @param onClick OnClick callback.
+ * @param onLongClick OnLongClick callback.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -52,12 +59,12 @@ fun BrowseFileItem(
onLongClick: () -> Unit
) {
val pattern = remember { SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault()) }
- val lastModified = remember { pattern.format(Date(file.first.lastModified())) }
- val sizeBytes = remember { file.first.length() }
+ val lastModified = rememberSaveable { pattern.format(Date(file.first.lastModified())) }
+ val sizeBytes = rememberSaveable { file.first.length() }
- val fileSizeKB = remember { if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0 }
- val fileSizeMB = remember { if (sizeBytes > 0) fileSizeKB / 1024.0 else 0.0 }
- val fileSize = remember {
+ val fileSizeKB = rememberSaveable { if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0 }
+ val fileSizeMB = rememberSaveable { if (sizeBytes > 0) fileSizeKB / 1024.0 else 0.0 }
+ val fileSize = rememberSaveable {
if (fileSizeMB >= 1.0) "%.2f MB".format(fileSizeMB)
else if (fileSizeMB > 0.0) "%.2f KB".format(fileSizeKB)
else "0 KB"
@@ -74,7 +81,7 @@ fun BrowseFileItem(
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 3.dp)
.clip(RoundedCornerShape(10.dp))
- .background(backgroundColor)
+ .background(backgroundColor, RoundedCornerShape(10.dp))
.combinedClickable(
onLongClick = {
onLongClick()
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/history/components/HistoryItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/history/components/HistoryItem.kt
index ac9b9fe0..796e379e 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/history/components/HistoryItem.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/history/components/HistoryItem.kt
@@ -40,6 +40,14 @@ import java.util.Locale
/**
* History list element item.
+ *
+ * @param modifier Modifier.
+ * @param history [History] item.
+ * @param isOnClickEnabled Whether OnClick is enabled.
+ * @param onBodyClick OnBodyClick callback.
+ * @param onTitleClick OnTitleClick callback.
+ * @param isDeleteEnabled Whether Delete button is enabled.
+ * @param onDeleteClick OnDeleteClick callback.
*/
@Composable
fun HistoryItem(
@@ -73,8 +81,10 @@ fun HistoryItem(
modifier = Modifier
.height(90.dp)
.width(60.dp)
- .clip(RoundedCornerShape(10.dp))
- .background(MaterialTheme.colorScheme.surfaceContainerLow)
+ .background(
+ MaterialTheme.colorScheme.surfaceContainerLow,
+ RoundedCornerShape(10.dp)
+ )
) {
if (history.book?.coverImage != null) {
CustomCoverImage(
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/LibraryScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/LibraryScreen.kt
index 003b01a7..412eba05 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/LibraryScreen.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/LibraryScreen.kt
@@ -47,7 +47,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
@@ -179,8 +178,10 @@ private fun LibraryScreen(
Text(
text = state.value.books.size.toString(),
modifier = Modifier
- .clip(RoundedCornerShape(14.dp))
- .background(MaterialTheme.colorScheme.surfaceContainer)
+ .background(
+ MaterialTheme.colorScheme.surfaceContainer,
+ RoundedCornerShape(14.dp)
+ )
.padding(horizontal = 8.dp, vertical = 3.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryBookItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryBookItem.kt
index b519556b..94800563 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryBookItem.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryBookItem.kt
@@ -39,6 +39,12 @@ import ua.acclorite.book_story.presentation.data.removeTrailingZero
/**
* Library list element item.
+ *
+ * @param book [Pair] of [Book] and [Selected] (Boolean).
+ * @param modifier Modifier.
+ * @param onCoverImageClick OnCoverImageClick callback.
+ * @param onLongClick OnLongClick callback.
+ * @param onButtonClick OnButtonClick callback.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -86,7 +92,10 @@ fun LibraryBookItem(
.aspectRatio(1f / 1.5f)
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
- .background(MaterialTheme.colorScheme.surfaceContainerLow)
+ .background(
+ MaterialTheme.colorScheme.surfaceContainerLow,
+ MaterialTheme.shapes.medium
+ )
.combinedClickable(
onClick = {
onCoverImageClick()
@@ -124,7 +133,7 @@ fun LibraryBookItem(
modifier = Modifier
.padding(6.dp)
.clip(MaterialTheme.shapes.small)
- .background(MaterialTheme.colorScheme.primary)
+ .background(MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small)
.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall
)
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryTabRow.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryTabRow.kt
index 4d32d4ac..7671ab3e 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryTabRow.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryTabRow.kt
@@ -21,7 +21,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@@ -113,8 +112,10 @@ fun LibraryTabRow(
Text(
text = tabItem.second.toString(),
modifier = Modifier
- .clip(MaterialTheme.shapes.medium)
- .background(MaterialTheme.colorScheme.surfaceContainer)
+ .background(
+ MaterialTheme.colorScheme.surfaceContainer,
+ MaterialTheme.shapes.medium
+ )
.padding(horizontal = 8.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt
index 2c7cc93e..6596814c 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt
@@ -15,10 +15,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
@@ -32,7 +29,6 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@@ -42,24 +38,21 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
-import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
-import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.debounce
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility
import ua.acclorite.book_story.presentation.components.CustomSelectionContainer
-import ua.acclorite.book_story.presentation.components.customItemsIndexed
+import ua.acclorite.book_story.presentation.components.customItems
import ua.acclorite.book_story.presentation.components.is_messages.IsError
+import ua.acclorite.book_story.presentation.components.translator_language.TranslatorLanguageBottomSheet
import ua.acclorite.book_story.presentation.data.LocalNavigator
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
@@ -72,8 +65,11 @@ import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewMode
import ua.acclorite.book_story.presentation.screens.reader.components.ReaderEndItem
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.download_language_dialog.ReaderDownloadLanguageDialog
import ua.acclorite.book_story.presentation.screens.reader.components.settings_bottom_sheet.ReaderSettingsBottomSheet
import ua.acclorite.book_story.presentation.screens.reader.components.start_item.ReaderStartItem
+import ua.acclorite.book_story.presentation.screens.reader.components.text.ReaderTextParagraph
+import ua.acclorite.book_story.presentation.screens.reader.components.translator_bottom_sheet.ReaderTranslatorBottomSheet
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel
@@ -88,12 +84,27 @@ fun ReaderScreenRoot() {
val libraryViewModel: LibraryViewModel = hiltViewModel()
val historyViewModel: HistoryViewModel = hiltViewModel()
- val listState = rememberLazyListState()
val loading = remember { mutableStateOf(true) }
val state = viewModel.state.collectAsState()
val mainState = mainViewModel.state.collectAsState()
+ val canScroll by remember {
+ derivedStateOf {
+ state.value.listState.canScrollBackward && state.value.listState.canScrollForward
+ }
+ }
+ val firstVisibleItemIndex = remember {
+ derivedStateOf {
+ state.value.listState.firstVisibleItemIndex
+ }
+ }
+ val firstVisibleItemOffset = remember {
+ derivedStateOf {
+ state.value.listState.firstVisibleItemScrollOffset
+ }
+ }
+
LaunchedEffect(Unit) {
viewModel.init(
navigator,
@@ -102,17 +113,37 @@ fun ReaderScreenRoot() {
libraryViewModel.onEvent(LibraryEvent.OnUpdateBook(it))
historyViewModel.onEvent(HistoryEvent.OnLoadList)
},
- listState = listState,
+ onError = {
+ Toast.makeText(
+ context,
+ it.asString(context),
+ Toast.LENGTH_LONG
+ ).show()
+ },
onLoaded = {
loading.value = false
}
)
}
+ LaunchedEffect(canScroll) {
+ if (!canScroll && !state.value.showMenu && !loading.value) {
+ viewModel.onEvent(ReaderEvent.OnShowHideMenu(context = context))
+ }
+ }
+ LaunchedEffect(firstVisibleItemIndex.value, firstVisibleItemOffset.value) {
+ viewModel.onUpdateProgress(
+ firstVisibleItemIndex = firstVisibleItemIndex.value,
+ firstVisibleItemOffset = firstVisibleItemOffset.value,
+ navigator = navigator,
+ isReaderLoading = loading.value,
+ onLibraryEvent = libraryViewModel::onEvent,
+ onHistoryEvent = historyViewModel::onEvent
+ )
+ }
ReaderScreen(
state = state,
mainState = mainState,
- listState = listState,
loading = loading,
navigator = navigator,
onEvent = viewModel::onEvent,
@@ -122,13 +153,11 @@ fun ReaderScreenRoot() {
)
}
-@OptIn(FlowPreview::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
private fun ReaderScreen(
state: State,
mainState: State,
- listState: LazyListState,
loading: State,
navigator: Navigator,
onEvent: (ReaderEvent) -> Unit,
@@ -137,13 +166,9 @@ private fun ReaderScreen(
onHistoryEvent: (HistoryEvent) -> Unit,
) {
val context = LocalContext.current as ComponentActivity
- val systemBarsColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(0.85f)
+ val density = LocalDensity.current
+ val systemBarsColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(0.94f)
- val canScroll by remember {
- derivedStateOf {
- listState.canScrollBackward && listState.canScrollForward
- }
- }
val nestedScrollConnection = remember(state) {
object : NestedScrollConnection {
override fun onPostScroll(
@@ -162,6 +187,9 @@ private fun ReaderScreen(
}
}
+ val text = remember(state.value.text) {
+ state.value.text.toList().sortedBy { it.first }
+ }
val fontFamily = remember(mainState.value.fontFamily) {
Constants.FONTS.find {
it.id == mainState.value.fontFamily
@@ -190,46 +218,45 @@ private fun ReaderScreen(
}
}
-
- LaunchedEffect(canScroll) {
- if (!canScroll && !state.value.showMenu && !loading.value) {
- onEvent(ReaderEvent.OnShowHideMenu(context = context))
- }
+ if (state.value.showTranslatorBottomSheet) {
+ ReaderTranslatorBottomSheet(
+ state = state,
+ onEvent = onEvent
+ )
}
+ if (state.value.showLanguageBottomSheet) {
+ TranslatorLanguageBottomSheet(
+ selectedLanguage = if (state.value.languageBottomSheetTranslateFrom) {
+ state.value.book.translateFrom
+ } else state.value.book.translateTo,
- LaunchedEffect(listState) {
- snapshotFlow {
- listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
- }.debounce(500).collectLatest { items ->
- if (!loading.value && state.value.text.isNotEmpty() && listState.layoutInfo.totalItemsCount > 0) {
- val lastVisibleItemIndex = listState.layoutInfo.visibleItemsInfo.last().index
-
- val progress = if (items.first > 0) {
- if (lastVisibleItemIndex >= (listState.layoutInfo.totalItemsCount - 1)) {
- 1f
- } else {
- (items.first.toFloat() / (state.value.text.lastIndex).toFloat())
- }
- } else {
- 0f
- }
+ unselectedLanguage = if (!state.value.languageBottomSheetTranslateFrom) {
+ state.value.book.translateFrom
+ } else state.value.book.translateTo,
+ translateFromSelecting = state.value.languageBottomSheetTranslateFrom,
+ onSelect = { from, to ->
onEvent(
- ReaderEvent.OnChangeProgress(
- progress = progress,
- navigator = navigator,
- firstVisibleItemIndex = items.first,
- firstVisibleItemOffset = items.second,
- refreshList = { book ->
- onLibraryEvent(LibraryEvent.OnUpdateBook(book))
- onHistoryEvent(HistoryEvent.OnUpdateBook(book))
- }
+ ReaderEvent.OnChangeTranslatorSettings(
+ translateFrom = from,
+ translateTo = to
+ )
+ )
+ onEvent(
+ ReaderEvent.OnShowHideLanguageBottomSheet(
+ show = false
+ )
+ )
+ },
+ onDismiss = {
+ onEvent(
+ ReaderEvent.OnShowHideLanguageBottomSheet(
+ show = false
)
)
}
- }
+ )
}
-
if (state.value.showSettingsBottomSheet) {
ReaderSettingsBottomSheet(
mainState = mainState,
@@ -237,6 +264,12 @@ private fun ReaderScreen(
onMainEvent = onMainEvent
)
}
+ if (state.value.showDownloadLanguageDialog) {
+ ReaderDownloadLanguageDialog(
+ state = state,
+ onEvent = onEvent
+ )
+ }
Scaffold(
Modifier
@@ -254,13 +287,13 @@ private fun ReaderScreen(
onEvent = onEvent,
onLibraryUpdateEvent = onLibraryEvent,
onHistoryUpdateEvent = onHistoryEvent,
- listState = listState,
containerColor = systemBarsColor
)
}
},
bottomBar = {
CustomAnimatedVisibility(
+ modifier = Modifier.fillMaxWidth(),
visible = state.value.showMenu,
enter = slideInVertically { it },
exit = slideOutVertically { it }
@@ -270,7 +303,6 @@ private fun ReaderScreen(
onEvent = onEvent,
onLibraryUpdateEvent = onLibraryEvent,
onHistoryUpdateEvent = onHistoryEvent,
- listState = listState,
containerColor = systemBarsColor
)
}
@@ -288,7 +320,7 @@ private fun ReaderScreen(
},
onTranslateRequested = { textToTranslate ->
onEvent(
- ReaderEvent.OnTranslateText(
+ ReaderEvent.OnOpenTranslator(
textToTranslate,
context,
noAppsFound = {
@@ -318,9 +350,10 @@ private fun ReaderScreen(
}
) { toolbarShowed ->
LazyColumn(
- state = listState,
+ state = state.value.listState,
modifier = Modifier
.fillMaxSize()
+ .background(backgroundColor)
.then(
if (!loading.value && toolbarShowed) {
Modifier
@@ -336,56 +369,49 @@ private fun ReaderScreen(
} else {
Modifier
}
- )
+ ),
+ verticalArrangement = Arrangement.spacedBy(paragraphHeight)
) {
if (state.value.text.isNotEmpty()) {
item {
DisableSelection {
ReaderStartItem(state = state)
}
+
+ Spacer(modifier = Modifier.height(18.dp))
}
}
- customItemsIndexed(
- state.value.text, key = { key -> key.id }
- ) { index, line ->
- val text = remember(mainState.value.paragraphIndentation, line) {
- "${if (mainState.value.paragraphIndentation!!) " " else ""}${line.line}"
- }
-
- Column(
- Modifier
- .background(backgroundColor)
- .fillMaxWidth()
- .padding(
- top = if (index == 0) 18.dp else 0.dp,
- start = sidePadding,
- end = sidePadding,
- bottom = if (index == state.value.text.lastIndex) 18.dp
- else paragraphHeight
- )
- ) {
- Text(
- text = text,
- color = fontColor,
- style = TextStyle(lineBreak = LineBreak.Paragraph),
- fontFamily = fontFamily.font,
- fontStyle = fontStyle,
- fontSize = mainState.value.fontSize!!.sp,
- lineHeight = lineHeight
- )
- }
+ customItems(
+ text, key = { key -> key.first }
+ ) { line ->
+ ReaderTextParagraph(
+ state = state,
+ id = line.first,
+ line = line.second,
+ context = context,
+ density = density,
+ fontFamily = fontFamily,
+ fontColor = fontColor,
+ lineHeight = lineHeight,
+ fontStyle = fontStyle,
+ fontSize = mainState.value.fontSize!!.sp,
+ sidePadding = sidePadding,
+ paragraphIndentation = mainState.value.paragraphIndentation!!,
+ onEvent = onEvent
+ )
}
if (state.value.text.isNotEmpty()) {
item {
+ Spacer(modifier = Modifier.height(18.dp))
+
DisableSelection {
ReaderEndItem(
state = state,
onEvent = onEvent,
onLibraryEvent = onLibraryEvent,
onHistoryUpdateEvent = onHistoryEvent,
- listState = listState
)
}
}
@@ -417,7 +443,6 @@ private fun ReaderScreen(
onLibraryEvent(LibraryEvent.OnUpdateBook(it))
onHistoryEvent(HistoryEvent.OnUpdateBook(it))
},
- listState = listState,
navigate = {
it.navigateBack()
}
@@ -452,7 +477,6 @@ private fun ReaderScreen(
onLibraryEvent(LibraryEvent.OnUpdateBook(it))
onHistoryEvent(HistoryEvent.OnUpdateBook(it))
},
- listState = listState,
navigate = {
it.navigateBack()
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderEndItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderEndItem.kt
index 6742b490..649ebb30 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderEndItem.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderEndItem.kt
@@ -9,11 +9,10 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
-import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -40,6 +39,11 @@ import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState
/**
* Reader end item. Displays at the end of the book.
+ *
+ * @param state [ReaderState].
+ * @param onEvent [ReaderEvent] callback.
+ * @param onLibraryEvent [LibraryEvent] callback.
+ * @param onHistoryUpdateEvent [HistoryEvent] callback.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -48,7 +52,6 @@ fun ReaderEndItem(
onEvent: (ReaderEvent) -> Unit,
onLibraryEvent: (LibraryEvent) -> Unit,
onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit,
- listState: LazyListState,
) {
val navigator = LocalNavigator.current
val context = LocalContext.current as ComponentActivity
@@ -62,7 +65,7 @@ fun ReaderEndItem(
Column(
modifier = Modifier
- .fillMaxWidth()
+ .fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.padding(
horizontal = 36.dp,
@@ -104,7 +107,6 @@ fun ReaderEndItem(
onLibraryEvent(LibraryEvent.OnUpdateCurrentPage(it))
},
navigator = navigator,
- listState = listState,
context = context
)
)
@@ -123,7 +125,6 @@ fun ReaderEndItem(
onLibraryEvent(LibraryEvent.OnUpdateBook(it))
onHistoryUpdateEvent(HistoryEvent.OnUpdateBook(it))
},
- listState = listState,
navigate = {
it.navigate(Screen.LIBRARY, true)
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt
index 2291b4d3..9cd839a9 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
@@ -33,6 +32,12 @@ import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState
/**
* Reader bottom bar. Has a slider to change progress.
+ *
+ * @param state [ReaderState].
+ * @param onEvent [ReaderEvent] callback.
+ * @param onLibraryUpdateEvent [LibraryEvent] callback.
+ * @param onHistoryUpdateEvent [HistoryEvent] callback.
+ * @param containerColor Container color.
*/
@Composable
fun ReaderBottomBar(
@@ -40,7 +45,6 @@ fun ReaderBottomBar(
onEvent: (ReaderEvent) -> Unit,
onLibraryUpdateEvent: (LibraryEvent.OnUpdateBook) -> Unit,
onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit,
- listState: LazyListState,
containerColor: Color
) {
val navigator = LocalNavigator.current
@@ -79,13 +83,13 @@ fun ReaderBottomBar(
value = state.value.book.progress,
enabled = !state.value.lockMenu,
onValueChange = {
- if (listState.layoutInfo.totalItemsCount > 0) {
- onEvent(ReaderEvent.OnScroll(listState, it))
+ if (state.value.listState.layoutInfo.totalItemsCount > 0) {
+ onEvent(ReaderEvent.OnScroll(it))
onEvent(
ReaderEvent.OnChangeProgress(
progress = it,
navigator = navigator,
- firstVisibleItemIndex = listState.firstVisibleItemIndex,
+ firstVisibleItemIndex = state.value.listState.firstVisibleItemIndex,
firstVisibleItemOffset = 0,
refreshList = { book ->
onLibraryUpdateEvent(LibraryEvent.OnUpdateBook(book))
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderTopBar.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderTopBar.kt
index 4d63334c..28f98f45 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderTopBar.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderTopBar.kt
@@ -5,11 +5,12 @@ import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -39,6 +40,12 @@ import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState
/**
* Reader top bar. Displays title of the book.
+ *
+ * @param state [ReaderState].
+ * @param onEvent [ReaderEvent] callback.
+ * @param onLibraryUpdateEvent [LibraryEvent] callback.
+ * @param onHistoryUpdateEvent [HistoryEvent] callback.
+ * @param containerColor Container color.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -47,7 +54,6 @@ fun ReaderTopBar(
onEvent: (ReaderEvent) -> Unit,
onLibraryUpdateEvent: (LibraryEvent.OnUpdateBook) -> Unit,
onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit,
- listState: LazyListState,
containerColor: Color
) {
val navigator = LocalNavigator.current
@@ -78,7 +84,6 @@ fun ReaderTopBar(
onLibraryUpdateEvent(LibraryEvent.OnUpdateBook(it))
onHistoryUpdateEvent(HistoryEvent.OnUpdateBook(it))
},
- listState = listState,
navigate = {
it.navigateBack()
}
@@ -93,6 +98,7 @@ fun ReaderTopBar(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 20.sp,
+ lineHeight = 20.sp,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
modifier = Modifier
@@ -109,7 +115,6 @@ fun ReaderTopBar(
onLibraryUpdateEvent(LibraryEvent.OnUpdateBook(it))
onHistoryUpdateEvent(HistoryEvent.OnUpdateBook(it))
},
- listState = listState,
navigate = {
it.navigateWithoutBackStack(
Screen.BOOK_INFO,
@@ -121,7 +126,7 @@ fun ReaderTopBar(
}
)
.basicMarquee(
- delayMillis = 4000
+ iterations = Int.MAX_VALUE
)
)
Text(
@@ -135,6 +140,17 @@ fun ReaderTopBar(
}
},
actions = {
+ CustomIconButton(
+ icon = Icons.Default.Translate,
+ contentDescription = R.string.translator_content_desc,
+ disableOnClick = false,
+ enabled = !state.value.lockMenu,
+ color = if (state.value.book.enableTranslator) {
+ MaterialTheme.colorScheme.primary
+ } else LocalContentColor.current
+ ) {
+ onEvent(ReaderEvent.OnShowHideTranslatorBottomSheet())
+ }
CustomIconButton(
icon = Icons.Default.Settings,
contentDescription = R.string.open_reader_settings_content_desc,
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialog.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialog.kt
new file mode 100644
index 00000000..7ee3d1f3
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialog.kt
@@ -0,0 +1,116 @@
+package ua.acclorite.book_story.presentation.screens.reader.components.download_language_dialog
+
+import android.widget.Toast
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import ua.acclorite.book_story.R
+import ua.acclorite.book_story.presentation.components.customItems
+import ua.acclorite.book_story.presentation.components.custom_dialog.CustomDialogWithLazyColumn
+import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent
+import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState
+
+/**
+ * Reader Download Language Dialog. Downloads multiple language models at the same time.
+ *
+ * @param state [ReaderState].
+ * @param onEvent [ReaderEvent] callback.
+ */
+@Composable
+fun ReaderDownloadLanguageDialog(
+ state: State,
+ onEvent: (ReaderEvent) -> Unit
+) {
+ val context = LocalContext.current
+ var isOnClickEnabled by remember { mutableStateOf(true) }
+ val isDownloading by remember {
+ derivedStateOf {
+ state.value.languagesToTranslate.any { it.isDownloading }
+ }
+ }
+ val isDownloaded by remember {
+ derivedStateOf {
+ state.value.languagesToTranslate.filter { it.isSelected }.none { !it.isDownloaded }
+ }
+ }
+ val languages = remember(state.value.languagesToTranslate, isDownloading) {
+ if (isDownloading) {
+ state.value.languagesToTranslate.sortedByDescending { it.isDownloading }
+ } else {
+ state.value.languagesToTranslate
+ }
+ }
+
+ CustomDialogWithLazyColumn(
+ title = stringResource(id = R.string.download_languages),
+ imageVectorIcon = Icons.Default.Download,
+ description = stringResource(
+ id = R.string.download_languages_description
+ ),
+ actionText = stringResource(
+ if (!isDownloaded) R.string.download
+ else R.string.continue_download
+ ),
+ disableOnClick = false,
+ isActionEnabled = isOnClickEnabled,
+ onDismiss = { onEvent(ReaderEvent.OnDismissDownloadLanguageDialog) },
+ onAction = {
+ isOnClickEnabled = false
+ onEvent(
+ ReaderEvent.OnDownloadLanguages(
+ onSuccess = {
+ Toast.makeText(
+ context,
+ context.getString(R.string.languages_downloaded),
+ Toast.LENGTH_LONG
+ ).show()
+
+ onEvent(
+ ReaderEvent.OnTranslateText(
+ it.keys,
+ error = { error ->
+ Toast.makeText(
+ context,
+ error.asString(context),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ )
+ )
+ },
+ error = {
+ Toast.makeText(
+ context,
+ it.asString(context),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ )
+ )
+ },
+ withDivider = true,
+ items = {
+ customItems(
+ languages, key = { it.languageCode }
+ ) {
+ ReaderDownloadLanguageDialogItem(
+ language = it,
+ isEnabled = !isDownloading,
+ onClick = {
+ onEvent(
+ ReaderEvent.OnSelectLanguage(it)
+ )
+ }
+ )
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialogItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialogItem.kt
new file mode 100644
index 00000000..86f4237e
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialogItem.kt
@@ -0,0 +1,177 @@
+package ua.acclorite.book_story.presentation.screens.reader.components.download_language_dialog
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyItemScope
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.DownloadDone
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+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.domain.model.SelectableLanguage
+import ua.acclorite.book_story.presentation.components.CustomCheckbox
+import ua.acclorite.book_story.presentation.ui.DefaultTransition
+
+/**
+ * Reader Download Language Dialog Item.
+ *
+ * @param language [SelectableLanguage] item.
+ * @param isEnabled Whether OnClick is enabled or not.
+ * @param onClick OnClick callback.
+ */
+@Composable
+fun LazyItemScope.ReaderDownloadLanguageDialogItem(
+ language: SelectableLanguage,
+ isEnabled: Boolean = true,
+ onClick: () -> Unit
+) {
+ val context = LocalContext.current
+
+ if (
+ isEnabled || language.isDownloading || language.isDownloaded || language.isError
+ ) {
+ Row(
+ modifier = Modifier
+ .animateItem(
+ fadeInSpec = null,
+ fadeOutSpec = null
+ )
+ .fillMaxWidth()
+ .clickable(
+ enabled = !language.isDownloading &&
+ !language.isDownloaded &&
+ language.canUnselect &&
+ isEnabled || language.isError
+ ) {
+ if (!language.isError) {
+ onClick()
+ } else if (language.errorMessage != null) {
+ Toast
+ .makeText(
+ context,
+ language.errorMessage.asString(context),
+ Toast.LENGTH_LONG
+ )
+ .show()
+ }
+ }
+ .padding(vertical = 12.dp, horizontal = 24.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = language.displayLanguage,
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (!language.occurrences.isNullOrEmpty()) {
+ Text(
+ text = stringResource(
+ id = R.string.translator_occurrences,
+ language.occurrences.size
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+
+ Box(contentAlignment = Alignment.CenterEnd) {
+ DefaultTransition(
+ language.isDownloading && !language.isDownloaded && !language.isError
+ ) {
+ Row {
+ Spacer(modifier = Modifier.width(24.dp))
+ CircularProgressIndicator(
+ strokeWidth = 2.5.dp,
+ modifier = Modifier
+ .size(22.dp),
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ }
+ }
+
+
+ DefaultTransition(
+ language.isDownloaded && !language.isDownloading && !language.isError
+ ) {
+ Row {
+ Spacer(modifier = Modifier.width(24.dp))
+ Icon(
+ imageVector = Icons.Default.DownloadDone,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .clip(CircleShape)
+ .background(
+ MaterialTheme.colorScheme.outlineVariant,
+ shape = CircleShape
+ )
+ .padding(6.dp)
+ .size(17.dp),
+ contentDescription = stringResource(id = R.string.downloaded_content_desc)
+ )
+ }
+ }
+
+ DefaultTransition(
+ !language.isDownloaded && !language.isDownloading && isEnabled && !language.isError
+ ) {
+ Row {
+ Spacer(modifier = Modifier.width(24.dp))
+ CustomCheckbox(
+ selected = language.isSelected,
+ size = 20.dp,
+ containerColor =
+ if (language.canUnselect) MaterialTheme.colorScheme.surfaceContainerHigh
+ else MaterialTheme.colorScheme.surfaceContainerHigh
+ )
+ }
+ }
+
+ DefaultTransition(
+ !language.isDownloaded && !language.isDownloading && language.isError
+ ) {
+ Row {
+ Spacer(modifier = Modifier.width(24.dp))
+ Icon(
+ imageVector = Icons.Default.Error,
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier
+ .size(27.dp),
+ contentDescription = stringResource(id = R.string.error_content_desc)
+ )
+ Spacer(modifier = Modifier.width(1.5.dp))
+ }
+ }
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt
index 3d297ac9..211953d8 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt
@@ -4,30 +4,22 @@ import androidx.activity.ComponentActivity
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@@ -38,6 +30,7 @@ import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.ButtonItem
import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.presentation.components.CategoryTitle
+import ua.acclorite.book_story.presentation.components.CustomBottomSheet
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent
@@ -48,10 +41,12 @@ import ua.acclorite.book_story.presentation.screens.settings.components.SwitchWi
/**
* Settings bottom sheet. Has General and Colors categories.
+ *
+ * @param mainState [MainState].
+ * @param onEvent [ReaderEvent] callback.
+ * @param onMainEvent [MainEvent] callback.
*/
-@OptIn(
- ExperimentalMaterial3Api::class
-)
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReaderSettingsBottomSheet(
mainState: State,
@@ -62,9 +57,6 @@ fun ReaderSettingsBottomSheet(
val currentPage by remember { derivedStateOf { pagerState.currentPage } }
val context = LocalContext.current
- val navigationBarPadding =
- WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
-
val fontFamily = remember(mainState.value.fontFamily) {
Constants.FONTS.find {
it.id == mainState.value.fontFamily
@@ -98,210 +90,205 @@ fun ReaderSettingsBottomSheet(
)
}
- Box(modifier = Modifier.fillMaxSize()) {
- ModalBottomSheet(
- scrimColor = animatedScrimColor,
- dragHandle = {},
- modifier = Modifier
- .align(Alignment.BottomCenter)
- .fillMaxWidth()
- .fillMaxHeight(animatedHeight),
- onDismissRequest = {
- onEvent(ReaderEvent.OnShowHideSettingsBottomSheet)
- },
- sheetState = rememberModalBottomSheetState(true),
- contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow
- ) {
- ReaderSettingsBottomSheetTabRow(
- onEvent = onEvent,
- pagerState = pagerState
- )
-
- HorizontalPager(state = pagerState) { page ->
- LazyColumn {
- if (page == 0) {
- item {
- Spacer(modifier = Modifier.height(22.dp))
- }
- item {
- CategoryTitle(
- title = stringResource(id = R.string.font_reader_settings),
- color = MaterialTheme.colorScheme.onSurface
- )
- }
- item {
- Spacer(modifier = Modifier.height(8.dp))
- }
- item {
- ChipsWithTitle(
- title = stringResource(id = R.string.font_family_option),
- chips = Constants.FONTS
- .map {
- ButtonItem(
- it.id,
- it.fontName.asString(),
- MaterialTheme.typography.labelLarge.copy(
- fontFamily = it.font
- ),
- it.id == fontFamily.id
- )
- },
- onClick = {
- onMainEvent(MainEvent.OnChangeFontFamily(it.id))
- }
- )
- }
- item {
- ChipsWithTitle(
- title = stringResource(id = R.string.font_style_option),
- chips = listOf(
- ButtonItem(
- "normal",
- stringResource(id = R.string.font_style_normal),
- MaterialTheme.typography.labelLarge.copy(
- fontFamily = fontFamily.font,
- fontStyle = FontStyle.Normal
- ),
- !mainState.value.isItalic!!
- ),
- ButtonItem(
- "italic",
- stringResource(id = R.string.font_style_italic),
- MaterialTheme.typography.labelLarge.copy(
- fontFamily = fontFamily.font,
- fontStyle = FontStyle.Italic
- ),
- mainState.value.isItalic!!
- ),
- ),
- onClick = {
- onMainEvent(
- MainEvent.OnChangeFontStyle(
- when (it.id) {
- "italic" -> true
- else -> false
- }
- )
- )
- }
- )
- }
- item {
- SliderWithTitle(
- value = mainState.value.fontSize!! to "pt",
- fromValue = 10,
- toValue = 35,
- title = stringResource(id = R.string.font_size_option),
- onValueChange = {
- onMainEvent(
- MainEvent.OnChangeFontSize(it)
- )
- }
- )
- }
- item {
- Spacer(modifier = Modifier.height(22.dp))
- }
- item {
- CategoryTitle(
- title = stringResource(id = R.string.text_reader_settings),
- color = MaterialTheme.colorScheme.onSurface
- )
- }
- item {
- Spacer(modifier = Modifier.height(8.dp))
- }
- item {
- SliderWithTitle(
- value = mainState.value.sidePadding!! to "pt",
- fromValue = 1,
- toValue = 20,
- title = stringResource(id = R.string.side_padding_option),
- onValueChange = {
- onMainEvent(
- MainEvent.OnChangeSidePadding(it)
- )
- }
- )
- }
- item {
- SliderWithTitle(
- value = mainState.value.lineHeight!! to "pt",
- fromValue = 1,
- toValue = 16,
- title = stringResource(id = R.string.line_height_option),
- onValueChange = {
- onMainEvent(
- MainEvent.OnChangeLineHeight(it)
- )
- }
- )
- }
- item {
- SliderWithTitle(
- value = mainState.value.paragraphHeight!! to "pt",
- fromValue = 0,
- toValue = 24,
- title = stringResource(id = R.string.paragraph_height_option),
- onValueChange = {
- onMainEvent(
- MainEvent.OnChangeParagraphHeight(it)
- )
- }
- )
- }
- item {
- SwitchWithTitle(
- selected = mainState.value.paragraphIndentation!!,
- title = stringResource(id = R.string.paragraph_indentation_option)
- ) {
- onMainEvent(
- MainEvent.OnChangeParagraphIndentation(!mainState.value.paragraphIndentation!!)
- )
- }
- }
- }
-
- if (page == 1) {
- item {
- Spacer(modifier = Modifier.height(14.dp))
- }
- item {
- ColorPickerWithTitle(
- value = Color(mainState.value.backgroundColor!!.toULong()),
- title = stringResource(id = R.string.background_color_option),
- onValueChange = {
- onMainEvent(
- MainEvent.OnChangeBackgroundColor(
- it.value.toLong()
- )
- )
- }
- )
- }
- item {
- ColorPickerWithTitle(
- value = Color(mainState.value.fontColor!!.toULong()),
- title = stringResource(id = R.string.font_color_option),
- onValueChange = {
- onMainEvent(
- MainEvent.OnChangeFontColor(
- it.value.toLong()
- )
- )
- }
- )
- }
- }
+ CustomBottomSheet(
+ hasFixedHeight = true,
+ scrimColor = animatedScrimColor,
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(animatedHeight),
+ dragHandle = {},
+ onDismissRequest = {
+ onEvent(ReaderEvent.OnShowHideSettingsBottomSheet)
+ }
+ ) {
+ ReaderSettingsBottomSheetTabRow(
+ onEvent = onEvent,
+ pagerState = pagerState
+ )
+ HorizontalPager(state = pagerState) { page ->
+ LazyColumn {
+ if (page == 0) {
item {
- Spacer(
- modifier = Modifier.height(
- 8.dp + navigationBarPadding
- )
+ Spacer(modifier = Modifier.height(22.dp))
+ }
+ item {
+ CategoryTitle(
+ title = stringResource(id = R.string.font_reader_settings),
+ color = MaterialTheme.colorScheme.onSurface
)
}
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ item {
+ ChipsWithTitle(
+ title = stringResource(id = R.string.font_family_option),
+ chips = Constants.FONTS
+ .map {
+ ButtonItem(
+ it.id,
+ it.fontName.asString(),
+ MaterialTheme.typography.labelLarge.copy(
+ fontFamily = it.font
+ ),
+ it.id == fontFamily.id
+ )
+ },
+ onClick = {
+ onMainEvent(MainEvent.OnChangeFontFamily(it.id))
+ }
+ )
+ }
+ item {
+ ChipsWithTitle(
+ title = stringResource(id = R.string.font_style_option),
+ chips = listOf(
+ ButtonItem(
+ "normal",
+ stringResource(id = R.string.font_style_normal),
+ MaterialTheme.typography.labelLarge.copy(
+ fontFamily = fontFamily.font,
+ fontStyle = FontStyle.Normal
+ ),
+ !mainState.value.isItalic!!
+ ),
+ ButtonItem(
+ "italic",
+ stringResource(id = R.string.font_style_italic),
+ MaterialTheme.typography.labelLarge.copy(
+ fontFamily = fontFamily.font,
+ fontStyle = FontStyle.Italic
+ ),
+ mainState.value.isItalic!!
+ ),
+ ),
+ onClick = {
+ onMainEvent(
+ MainEvent.OnChangeFontStyle(
+ when (it.id) {
+ "italic" -> true
+ else -> false
+ }
+ )
+ )
+ }
+ )
+ }
+ item {
+ SliderWithTitle(
+ value = mainState.value.fontSize!! to "pt",
+ fromValue = 10,
+ toValue = 35,
+ title = stringResource(id = R.string.font_size_option),
+ onValueChange = {
+ onMainEvent(
+ MainEvent.OnChangeFontSize(it)
+ )
+ }
+ )
+ }
+ item {
+ Spacer(modifier = Modifier.height(22.dp))
+ }
+ item {
+ CategoryTitle(
+ title = stringResource(id = R.string.text_reader_settings),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ item {
+ SliderWithTitle(
+ value = mainState.value.sidePadding!! to "pt",
+ fromValue = 1,
+ toValue = 20,
+ title = stringResource(id = R.string.side_padding_option),
+ onValueChange = {
+ onMainEvent(
+ MainEvent.OnChangeSidePadding(it)
+ )
+ }
+ )
+ }
+ item {
+ SliderWithTitle(
+ value = mainState.value.lineHeight!! to "pt",
+ fromValue = 1,
+ toValue = 16,
+ title = stringResource(id = R.string.line_height_option),
+ onValueChange = {
+ onMainEvent(
+ MainEvent.OnChangeLineHeight(it)
+ )
+ }
+ )
+ }
+ item {
+ SliderWithTitle(
+ value = mainState.value.paragraphHeight!! to "pt",
+ fromValue = 0,
+ toValue = 24,
+ title = stringResource(id = R.string.paragraph_height_option),
+ onValueChange = {
+ onMainEvent(
+ MainEvent.OnChangeParagraphHeight(it)
+ )
+ }
+ )
+ }
+ item {
+ SwitchWithTitle(
+ selected = mainState.value.paragraphIndentation!!,
+ title = stringResource(id = R.string.paragraph_indentation_option)
+ ) {
+ onMainEvent(
+ MainEvent.OnChangeParagraphIndentation(!mainState.value.paragraphIndentation!!)
+ )
+ }
+ }
+ }
+
+ if (page == 1) {
+ item {
+ Spacer(modifier = Modifier.height(14.dp))
+ }
+ item {
+ ColorPickerWithTitle(
+ value = Color(mainState.value.backgroundColor!!.toULong()),
+ title = stringResource(id = R.string.background_color_option),
+ onValueChange = {
+ onMainEvent(
+ MainEvent.OnChangeBackgroundColor(
+ it.value.toLong()
+ )
+ )
+ }
+ )
+ }
+ item {
+ ColorPickerWithTitle(
+ value = Color(mainState.value.fontColor!!.toULong()),
+ title = stringResource(id = R.string.font_color_option),
+ onValueChange = {
+ onMainEvent(
+ MainEvent.OnChangeFontColor(
+ it.value.toLong()
+ )
+ )
+ }
+ )
+ }
+ }
+
+ item {
+ Spacer(
+ modifier = Modifier.height(
+ 8.dp + it
+ )
+ )
}
}
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/start_item/ReaderStartItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/start_item/ReaderStartItem.kt
index ea878582..13443b79 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/start_item/ReaderStartItem.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/start_item/ReaderStartItem.kt
@@ -37,6 +37,8 @@ import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState
/**
* Reader start item. Displays at the beginning of the book.
+ *
+ * @param state [ReaderState].
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -76,8 +78,10 @@ fun ReaderStartItem(state: State) {
modifier = Modifier
.height(150.dp)
.width(100.dp)
- .clip(RoundedCornerShape(10.dp))
- .background(MaterialTheme.colorScheme.surfaceContainerLow)
+ .background(
+ MaterialTheme.colorScheme.surfaceContainerLow,
+ RoundedCornerShape(10.dp)
+ )
) {
if (state.value.book.coverImage != null) {
CustomCoverImage(
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextParagraph.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextParagraph.kt
new file mode 100644
index 00000000..c17168ef
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextParagraph.kt
@@ -0,0 +1,224 @@
+package ua.acclorite.book_story.presentation.screens.reader.components.text
+
+import android.content.Context
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyItemScope
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.text.appendInlineContent
+import androidx.compose.foundation.text.selection.DisableSelection
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+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.LineBreak
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import ua.acclorite.book_story.R
+import ua.acclorite.book_story.domain.model.FontWithName
+import ua.acclorite.book_story.domain.model.LineWithTranslation
+import ua.acclorite.book_story.domain.util.ID
+import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility
+import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent
+import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState
+import java.util.Locale
+
+/**
+ * Reader Text Paragraph item.
+ *
+ * @param state [ReaderState].
+ * @param id Current paragraph [ID].
+ * @param line Current line.
+ * @param context Context.
+ * @param density [Density] from LocalDensity.current.
+ * @param fontFamily Line's font family.
+ * @param fontColor Line's font color.
+ * @param lineHeight Line's line height.
+ * @param fontStyle Line's font style.
+ * @param fontSize Line's font size.
+ * @param sidePadding Line's side padding.
+ * @param paragraphIndentation Whether Paragraph Indentation is enabled for this line.
+ * @param onEvent [ReaderEvent] callback.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun LazyItemScope.ReaderTextParagraph(
+ state: State,
+ id: ID,
+ line: LineWithTranslation,
+ context: Context,
+ density: Density,
+ fontFamily: FontWithName,
+ fontColor: Color,
+ lineHeight: TextUnit,
+ fontStyle: FontStyle,
+ fontSize: TextUnit,
+ sidePadding: Dp,
+ paragraphIndentation: Boolean,
+ onEvent: (ReaderEvent) -> Unit
+) {
+ val text = remember(
+ paragraphIndentation,
+ line.originalLine,
+ line.translatedLine,
+ line.useTranslation,
+ line.translatingTo,
+ line.translatingFrom
+ ) {
+ derivedStateOf {
+ if (paragraphIndentation) " "
+ else "" +
+ if (line.useTranslation && line.translatedLine != null) line.translatedLine
+ else line.originalLine
+ }
+ }
+
+ val translated = remember(line.useTranslation, line.translatedLine) {
+ line.useTranslation && line.translatedLine != null
+ }
+ val showTrailingContent = remember(
+ line.isTranslationLoading, line.isTranslationFailed
+ ) {
+ line.isTranslationLoading || line.isTranslationFailed
+ }
+
+ val translatingFrom = remember(line.translatingFrom) {
+ val locale = Locale(line.translatingFrom)
+ locale.getDisplayName(locale).replaceFirstChar { char ->
+ char.uppercase()
+ }
+ }
+ val translatingTo = remember(line.translatingTo) {
+ val locale = Locale(line.translatingTo)
+ locale.getDisplayName(locale).replaceFirstChar { char ->
+ char.uppercase()
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .animateItem(fadeInSpec = null, fadeOutSpec = null)
+ .fillMaxWidth()
+ .padding(
+ horizontal = sidePadding
+ ),
+ verticalArrangement = Arrangement.Center
+ ) {
+ CustomAnimatedVisibility(
+ modifier = Modifier.animateItem(
+ placementSpec = tween(10),
+ fadeInSpec = null,
+ fadeOutSpec = null
+ ),
+ visible = translated,
+ enter = fadeIn(tween(250)) + expandVertically(tween(250)),
+ exit = fadeOut(tween(250)) + shrinkVertically(tween(250)),
+ ) {
+ DisableSelection {
+ Text(
+ text = stringResource(
+ id = R.string.translator_show_original,
+ translatingFrom,
+ translatingTo
+ ),
+ color = fontColor,
+ fontStyle = FontStyle.Italic,
+ fontWeight = FontWeight.Light,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.clickable(interactionSource = null, indication = null) {
+ onEvent(ReaderEvent.OnUndoTranslation(id))
+ }
+ )
+ }
+ }
+
+ BasicText(
+ text = buildAnnotatedString {
+ append(text.value)
+
+ if (showTrailingContent) {
+ append(" ")
+ appendInlineContent("translator")
+ }
+ },
+ modifier = Modifier
+ .then(
+ if (
+ state.value.book.enableTranslator &&
+ state.value.book.doubleClickTranslation
+ ) {
+ Modifier.combinedClickable(
+ interactionSource = null,
+ indication = null,
+ onDoubleClick = {
+ if (line.isTranslationLoading) {
+ return@combinedClickable
+ }
+
+ if (translated) {
+ onEvent(ReaderEvent.OnUndoTranslation(id))
+ } else {
+ onEvent(
+ ReaderEvent.OnTranslateText(setOf(id)) {
+ Toast
+ .makeText(
+ context,
+ it.asString(context),
+ Toast.LENGTH_LONG
+ )
+ .show()
+ }
+ )
+ }
+ },
+ onClick = {
+ onEvent(
+ ReaderEvent.OnShowHideMenu(context = context as ComponentActivity)
+ )
+ }
+ )
+ } else {
+ Modifier
+ }
+ ),
+ inlineContent = readerTextTrailingContent(
+ line = line,
+ color = fontColor,
+ textSize = fontSize,
+ context = context,
+ density = density
+ ),
+ style = TextStyle(
+ fontFamily = fontFamily.font,
+ fontStyle = fontStyle,
+ fontSize = fontSize,
+ lineHeight = lineHeight,
+ color = fontColor,
+ lineBreak = LineBreak.Paragraph
+ )
+ )
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextTrailingContent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextTrailingContent.kt
new file mode 100644
index 00000000..55c9850e
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextTrailingContent.kt
@@ -0,0 +1,97 @@
+package ua.acclorite.book_story.presentation.screens.reader.components.text
+
+import android.content.Context
+import android.widget.Toast
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.text.InlineTextContent
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.Placeholder
+import androidx.compose.ui.text.PlaceholderVerticalAlign
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.TextUnit
+import ua.acclorite.book_story.R
+import ua.acclorite.book_story.domain.model.LineWithTranslation
+import ua.acclorite.book_story.presentation.ui.DefaultTransition
+
+/**
+ * [ReaderTextParagraph]'s trailing content.
+ *
+ * @param line [ReaderTextParagraph]'s line.
+ * @param color Color.
+ * @param textSize Font size.
+ * @param context Context.
+ * @param density [Density] from LocalDensity.current.
+ */
+@Composable
+fun readerTextTrailingContent(
+ line: LineWithTranslation,
+ color: Color,
+ textSize: TextUnit,
+ context: Context,
+ density: Density
+): Map {
+ val sizeDp = remember(textSize, density) { with(density) { textSize.toDp() } }
+ return remember(line.isTranslationLoading, line.isTranslationFailed, sizeDp, color, textSize) {
+ mapOf(
+ Pair(
+ "translator",
+ InlineTextContent(
+ placeholder = Placeholder(
+ width = textSize,
+ height = textSize,
+ placeholderVerticalAlign = PlaceholderVerticalAlign.Center
+ ),
+ ) {
+ Box(
+ Modifier.fillMaxSize(),
+ contentAlignment = Alignment.CenterEnd
+ ) {
+ DefaultTransition(line.isTranslationLoading) {
+ CircularProgressIndicator(
+ strokeWidth = sizeDp / 12,
+ modifier = Modifier
+ .fillMaxSize(0.7f),
+ color = color
+ )
+ }
+
+ DefaultTransition(line.isTranslationFailed && !line.isTranslationLoading) {
+ Icon(
+ imageVector = Icons.Filled.Error,
+ contentDescription = stringResource(id = R.string.error_content_desc),
+ tint = color,
+ modifier = Modifier
+ .fillMaxSize()
+ .clickable(
+ interactionSource = null,
+ indication = null
+ ) {
+ line.errorMessage?.let {
+ Toast
+ .makeText(
+ context,
+ it.asString(context),
+ Toast.LENGTH_LONG
+ )
+ .show()
+ }
+ }
+ )
+ }
+ }
+ }
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheet.kt
new file mode 100644
index 00000000..3edea813
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheet.kt
@@ -0,0 +1,321 @@
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package ua.acclorite.book_story.presentation.screens.reader.components.translator_bottom_sheet
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import ua.acclorite.book_story.R
+import ua.acclorite.book_story.presentation.components.CustomBottomSheet
+import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent
+import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState
+import ua.acclorite.book_story.presentation.screens.settings.components.SwitchWithTitle
+import java.util.Locale
+
+/**
+ * Reader Translator Bottom Sheet. You can set up preferences for an individual book here and apply/cancel translation.
+ *
+ * @param state [ReaderState].
+ * @param onEvent [ReaderEvent] callback.
+ */
+@Composable
+fun ReaderTranslatorBottomSheet(
+ state: State,
+ onEvent: (ReaderEvent) -> Unit
+) {
+ val context = LocalContext.current
+ val book = remember(state.value) { state.value.book }
+
+ val translateFrom = remember(book.translateFrom) {
+ if (book.translateFrom == "auto") {
+ return@remember context.getString(R.string.translator_auto_short)
+ }
+
+ val locale = Locale(book.translateFrom)
+ locale.getDisplayName(locale).replaceFirstChar { char ->
+ char.uppercase()
+ }
+ }
+ val translateTo = remember(book.translateTo) {
+ val locale = Locale(book.translateTo)
+ locale.getDisplayName(locale).replaceFirstChar { char ->
+ char.uppercase()
+ }
+ }
+
+ val loading = remember { mutableStateOf(false) }
+ val textTranslated = remember(state.value.isTranslating, state.value.text) {
+ loading.value = true
+
+ val hasTranslatedText = state.value.text.any {
+ it.value.useTranslation && it.value.translatedLine != null
+ }
+ val loaded = state.value.text.none {
+ it.value.isTranslationLoading
+ }
+
+ loading.value = false
+
+ !state.value.isTranslating && hasTranslatedText && loaded
+ }
+
+ CustomBottomSheet(
+ onDismissRequest = {
+ onEvent(ReaderEvent.OnShowHideTranslatorBottomSheet())
+ }
+ ) {
+ LazyColumn(Modifier.fillMaxWidth()) {
+ item {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 18.dp)
+ .clip(RoundedCornerShape(25.dp))
+ .background(
+ if (book.enableTranslator) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.outline,
+ RoundedCornerShape(25.dp)
+ )
+ .clickable {
+ if (book.enableTranslator) {
+ onEvent(ReaderEvent.OnCancelTranslation)
+ }
+
+ onEvent(
+ ReaderEvent.OnChangeTranslatorSettings(
+ enableTranslator = !book.enableTranslator
+ )
+ )
+ }
+ .padding(horizontal = 15.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = stringResource(id = R.string.translator_reader_settings),
+ color = if (book.enableTranslator) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.surface,
+ fontSize = 17.5.sp
+ )
+ Switch(
+ checked = book.enableTranslator,
+ onCheckedChange = null,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = MaterialTheme.colorScheme.primary,
+ checkedTrackColor = MaterialTheme.colorScheme.secondaryContainer,
+ uncheckedBorderColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ )
+ )
+ }
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+
+ item {
+ ReaderTranslatorBottomSheetLanguageChooser(
+ fromLanguage = translateFrom,
+ toLanguage = translateTo,
+ onFromClick = {
+ onEvent(
+ ReaderEvent.OnShowHideLanguageBottomSheet(
+ show = true,
+ translateFrom = true
+ )
+ )
+ },
+ onToClick = {
+ onEvent(
+ ReaderEvent.OnShowHideLanguageBottomSheet(
+ show = true,
+ translateFrom = false
+ )
+ )
+ },
+ enabled = !state.value.isTranslating,
+ switchEnabled = book.translateFrom != "auto",
+ onSwitchClick = {
+ onEvent(
+ ReaderEvent.OnChangeTranslatorSettings(
+ translateFrom = book.translateTo,
+ translateTo = book.translateFrom
+ )
+ )
+ }
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(6.dp))
+ }
+
+ item {
+ SwitchWithTitle(
+ selected = book.doubleClickTranslation,
+ title = stringResource(id = R.string.translator_double_click_to_translate_option),
+ description = stringResource(id = R.string.translator_double_click_to_translate_option_desc),
+ verticalPadding = 6.dp,
+ onClick = {
+ onEvent(
+ ReaderEvent.OnChangeTranslatorSettings(
+ doubleClickTranslation = !book.doubleClickTranslation
+ )
+ )
+ }
+ )
+ }
+
+ item {
+ SwitchWithTitle(
+ selected = book.translateWhenOpen,
+ title = stringResource(id = R.string.translator_translate_on_start_option),
+ description = stringResource(id = R.string.translator_translate_on_start_option_desc),
+ verticalPadding = 6.dp,
+ onClick = {
+ onEvent(
+ ReaderEvent.OnChangeTranslatorSettings(
+ translateWhenOpen = !book.translateWhenOpen
+ )
+ )
+ }
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(48.dp))
+ }
+
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 18.dp),
+ contentAlignment = Alignment.CenterEnd
+ ) {
+ Button(
+ modifier = Modifier.fillMaxWidth(1f),
+ onClick = {
+ if (loading.value) {
+ return@Button
+ }
+
+ if (!state.value.isTranslating) {
+ if (!textTranslated) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.translator_starting),
+ Toast.LENGTH_SHORT
+ ).show()
+ onEvent(
+ ReaderEvent.OnTranslateText(
+ keys = null, /* means translating the whole text */
+ error = {
+ Toast.makeText(
+ context,
+ it.asString(context),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ )
+ )
+ } else {
+ Toast.makeText(
+ context,
+ context.getString(R.string.translator_text_canceling),
+ Toast.LENGTH_SHORT
+ ).show()
+ onEvent(
+ ReaderEvent.OnUndoTranslation(
+ null /* means canceling the whole text translation */
+ )
+ )
+ }
+ } else {
+ Toast.makeText(
+ context,
+ context.getString(R.string.translator_canceling),
+ Toast.LENGTH_SHORT
+ ).show()
+
+ loading.value = true
+ onEvent(
+ ReaderEvent.OnCancelTranslation
+ )
+ }
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = if (state.value.isTranslating || textTranslated) {
+ MaterialTheme.colorScheme.secondary
+ } else {
+ MaterialTheme.colorScheme.primary
+ },
+ contentColor = contentColorFor(
+ if (state.value.isTranslating || textTranslated) {
+ MaterialTheme.colorScheme.secondary
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
+ )
+ )
+ ) {
+ if (!state.value.isTranslating && !loading.value) {
+ Text(
+ text = if (textTranslated) stringResource(
+ id = R.string.undo_translation
+ ) else stringResource(id = R.string.translate)
+ )
+ } else {
+ CircularProgressIndicator(
+ color = LocalContentColor.current,
+ strokeCap = StrokeCap.Round,
+ modifier = Modifier.size(21.dp),
+ strokeWidth = 2.3.dp
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ Spacer(
+ modifier = Modifier.height(
+ 8.dp + it
+ )
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheetLanguageChooser.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheetLanguageChooser.kt
new file mode 100644
index 00000000..192b2ac4
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheetLanguageChooser.kt
@@ -0,0 +1,191 @@
+package ua.acclorite.book_story.presentation.screens.reader.components.translator_bottom_sheet
+
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.CompareArrows
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.yield
+import ua.acclorite.book_story.R
+import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility
+import ua.acclorite.book_story.presentation.components.CustomIconButton
+
+/**
+ * Reader Translator BottomSheet Language Chooser.
+ *
+ * @param fromLanguage Current translate from language.
+ * @param toLanguage Current translate to language.
+ * @param enabled Whether it is enabled.
+ * @param switchEnabled Whether switch languages button is enabled.
+ * @param horizontalPadding Horizontal item padding.
+ * @param onFromClick OnFromClick callback.
+ * @param onToClick OnToClick callback.
+ * @param onSwitchClick OnSwitchClick callback.
+ */
+@Composable
+fun ReaderTranslatorBottomSheetLanguageChooser(
+ fromLanguage: String,
+ toLanguage: String,
+ enabled: Boolean = true,
+ switchEnabled: Boolean = true,
+ horizontalPadding: Dp = 18.dp,
+ onFromClick: () -> Unit,
+ onToClick: () -> Unit,
+ onSwitchClick: () -> Unit
+) {
+ val switchLanguage = remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+ var job: Job? = null
+
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = horizontalPadding),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(modifier = Modifier.weight(1f)) {
+ CustomLanguageSwitchingAnimation(
+ visible = switchLanguage.value,
+ rightPlacement = false
+ ) {
+ LanguageItem(text = fromLanguage, enabled = enabled) {
+ onFromClick()
+ }
+ }
+
+ CustomLanguageSwitchingAnimation(
+ visible = !switchLanguage.value,
+ rightPlacement = false
+ ) {
+ LanguageItem(text = fromLanguage, enabled = enabled) {
+ onFromClick()
+ }
+ }
+ }
+
+
+
+ Spacer(modifier = Modifier.width(4.dp))
+ CustomIconButton(
+ icon = Icons.AutoMirrored.Filled.CompareArrows,
+ contentDescription = R.string.translator_language_content_desc,
+ disableOnClick = false,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(24.dp),
+ enabled = enabled && switchEnabled,
+ onClick = {
+ job?.cancel()
+ job = scope.launch {
+ switchLanguage.value = !switchLanguage.value
+
+ delay(100)
+ yield()
+
+ onSwitchClick()
+ }
+ }
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+
+ Box(modifier = Modifier.weight(1f)) {
+ CustomLanguageSwitchingAnimation(
+ visible = switchLanguage.value,
+ rightPlacement = true
+ ) {
+ LanguageItem(text = toLanguage, enabled = enabled) {
+ onToClick()
+ }
+ }
+
+ CustomLanguageSwitchingAnimation(
+ visible = !switchLanguage.value,
+ rightPlacement = true
+ ) {
+ LanguageItem(text = toLanguage, enabled = enabled) {
+ onToClick()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CustomLanguageSwitchingAnimation(
+ visible: Boolean,
+ rightPlacement: Boolean,
+ content: @Composable () -> Unit
+) {
+ val exitRightAnimation = remember {
+ fadeOut(tween(200)) +
+ slideOutHorizontally(tween(250)) { -it / 2 } +
+ scaleOut(tween(250), 0.4f)
+ }
+ val enterRightAnimation = remember {
+ slideInHorizontally(tween(350)) { -it / 2 } + fadeIn(tween(200))
+ }
+
+ val exitLeftAnimation = remember {
+ fadeOut(tween(200)) +
+ slideOutHorizontally(tween(250)) { it / 2 } +
+ scaleOut(tween(250), 0.4f)
+ }
+ val enterLeftAnimation = remember {
+ slideInHorizontally(tween(350)) { it / 2 } + fadeIn(tween(200))
+ }
+
+ CustomAnimatedVisibility(
+ visible = visible,
+ enter = if (rightPlacement) enterRightAnimation else enterLeftAnimation,
+ exit = if (rightPlacement) exitRightAnimation else exitLeftAnimation
+ ) {
+ content()
+ }
+}
+
+@Composable
+private fun LanguageItem(text: String, enabled: Boolean, onClick: () -> Unit) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(MaterialTheme.shapes.large)
+ .background(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.shapes.large)
+ .clickable(enabled = enabled) {
+ onClick()
+ }
+ .padding(horizontal = 4.dp, vertical = 14.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = text,
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt
index b42b731b..4ea7e720 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt
@@ -1,29 +1,70 @@
package ua.acclorite.book_story.presentation.screens.reader.data
import androidx.activity.ComponentActivity
-import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.Book
+import ua.acclorite.book_story.domain.model.LineWithTranslation
+import ua.acclorite.book_story.domain.model.SelectableLanguage
+import ua.acclorite.book_story.domain.util.ID
+import ua.acclorite.book_story.domain.util.LanguageCode
+import ua.acclorite.book_story.domain.util.UIText
import ua.acclorite.book_story.presentation.data.Navigator
@Immutable
sealed class ReaderEvent {
data class OnTextIsEmpty(val onLoaded: () -> Unit) : ReaderEvent()
data class OnLoadText(
- val listState: LazyListState,
val refreshList: (Book) -> Unit,
val onLoaded: () -> Unit,
+ val onError: (UIText) -> Unit,
val onTextIsEmpty: () -> Unit
) : ReaderEvent()
data class OnShowHideMenu(val show: Boolean? = null, val context: ComponentActivity) :
ReaderEvent()
+ data class OnShowHideTranslatorBottomSheet(val show: Boolean? = null) : ReaderEvent()
+
+ data class OnChangeTranslatorSettings(
+ val enableTranslator: Boolean? = null,
+ val translateFrom: String? = null,
+ val translateTo: String? = null,
+ val doubleClickTranslation: Boolean? = null,
+ val translateWhenOpen: Boolean? = null
+ ) : ReaderEvent()
+
+ data class OnShowHideLanguageBottomSheet(
+ val show: Boolean? = null,
+ val translateFrom: Boolean? = null
+ ) : ReaderEvent()
+
+ data class OnTranslateText(
+ val keys: Set?,
+ val error: suspend (UIText) -> Unit
+ ) : ReaderEvent()
+
+ data object OnDismissDownloadLanguageDialog : ReaderEvent()
+
+ data object OnCancelTranslation : ReaderEvent()
+
+ data class OnUndoTranslation(val id: ID?) : ReaderEvent()
+
+ data class OnDownloadLanguages(
+ val onSuccess: suspend (Map) -> Unit,
+ val error: suspend (UIText) -> Unit
+ ) : ReaderEvent()
+
+ data class OnSelectLanguage(val language: SelectableLanguage) : ReaderEvent()
+
+ data class OnUpdateLanguage(
+ val languageCode: LanguageCode,
+ val calculation: (SelectableLanguage) -> SelectableLanguage
+ ) : ReaderEvent()
+
data class OnGoBack(
val context: ComponentActivity,
val navigator: Navigator,
- val listState: LazyListState,
val refreshList: (Book) -> Unit,
val navigate: (Navigator) -> Unit
) : ReaderEvent()
@@ -36,18 +77,17 @@ sealed class ReaderEvent {
val refreshList: (Book) -> Unit
) : ReaderEvent()
- data class OnScroll(val listState: LazyListState, val progress: Float) : ReaderEvent()
+ data class OnScroll(val progress: Float) : ReaderEvent()
data object OnShowHideSettingsBottomSheet : ReaderEvent()
data class OnScrollToSettingsPage(val page: Int, val pagerState: PagerState) : ReaderEvent()
data class OnMoveBookToAlreadyRead(
val context: ComponentActivity,
- val listState: LazyListState,
val onUpdateCategories: (Book) -> Unit,
val updatePage: (Int) -> Unit,
val navigator: Navigator
) : ReaderEvent()
- data class OnTranslateText(
+ data class OnOpenTranslator(
val textToTranslate: String,
val context: ComponentActivity,
val noAppsFound: () -> Unit
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt
index e0846923..5b190ebc 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt
@@ -1,23 +1,35 @@
package ua.acclorite.book_story.presentation.screens.reader.data
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.Book
-import ua.acclorite.book_story.domain.model.StringWithId
+import ua.acclorite.book_story.domain.model.LineWithTranslation
+import ua.acclorite.book_story.domain.model.SelectableLanguage
import ua.acclorite.book_story.domain.util.Constants
+import ua.acclorite.book_story.domain.util.ID
import ua.acclorite.book_story.domain.util.UIText
@Immutable
data class ReaderState(
val book: Book = Constants.EMPTY_BOOK,
- val text: List = emptyList(),
+ val text: Map = emptyMap(),
val words: Int = 0,
val letters: Int = 0,
+ val listState: LazyListState = LazyListState(),
+
+ val languagesToTranslate: List = emptyList(),
+ val showDownloadLanguageDialog: Boolean = false,
+ val isTranslating: Boolean = false,
val errorMessage: UIText? = null,
val showMenu: Boolean = false,
val lockMenu: Boolean = false,
+ val showTranslatorBottomSheet: Boolean = false,
+ val showLanguageBottomSheet: Boolean = false,
+ val languageBottomSheetTranslateFrom: Boolean = false,
+
val showSettingsBottomSheet: Boolean = false,
val currentPage: Int = 0,
)
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt
index 2ee02f20..4533c717 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt
@@ -4,7 +4,6 @@ import android.app.SearchManager
import android.content.Intent
import android.util.Log
import androidx.activity.ComponentActivity
-import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.snapshotFlow
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -13,37 +12,72 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.yield
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category
+import ua.acclorite.book_story.domain.model.LineWithTranslation
+import ua.acclorite.book_story.domain.model.SelectableLanguage
+import ua.acclorite.book_story.domain.use_case.DeleteLanguageModel
+import ua.acclorite.book_story.domain.use_case.DownloadLanguageModel
import ua.acclorite.book_story.domain.use_case.GetBooksById
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.IdentifyLanguage
+import ua.acclorite.book_story.domain.use_case.IsLanguageModelDownloaded
+import ua.acclorite.book_story.domain.use_case.TranslateText
import ua.acclorite.book_story.domain.use_case.UpdateBooks
+import ua.acclorite.book_story.domain.util.ID
+import ua.acclorite.book_story.domain.util.LanguageCode
+import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText
import ua.acclorite.book_story.presentation.data.Argument
import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.data.Screen
+import ua.acclorite.book_story.presentation.data.removeKey
+import ua.acclorite.book_story.presentation.data.removeKeys
+import ua.acclorite.book_story.presentation.data.update
+import ua.acclorite.book_story.presentation.data.updateWithCopy
+import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent
+import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
+import java.util.Locale
import javax.inject.Inject
import kotlin.math.roundToInt
+@Suppress("LABEL_NAME_CLASH")
@HiltViewModel
class ReaderViewModel @Inject constructor(
private val updateBooks: UpdateBooks,
private val getText: GetText,
private val getLatestHistory: GetLatestHistory,
- private val getBooksById: GetBooksById
+ private val getBooksById: GetBooksById,
+ private val identifyLanguage: IdentifyLanguage,
+ private val isLanguageModelDownloaded: IsLanguageModelDownloaded,
+ private val downloadLanguageModel: DownloadLanguageModel,
+ private val deleteLanguageModel: DeleteLanguageModel,
+ private val translateText: TranslateText,
) : ViewModel() {
private val _state = MutableStateFlow(ReaderState())
val state = _state.asStateFlow()
+ private var dismissTranslatorJob: Job? = null
+ private var cancelTranslationJob: Job? = null
+ private var translatorJob: Job? = null
+ private var undoTranslationJob: Job? = null
+ private var downloadLanguageJob: Job? = null
+
fun onEvent(event: ReaderEvent) {
when (event) {
is ReaderEvent.OnTextIsEmpty -> {
@@ -56,90 +90,106 @@ class ReaderViewModel @Inject constructor(
}
is ReaderEvent.OnLoadText -> {
- viewModelScope.launch(Dispatchers.IO) {
- val text = getText.execute(_state.value.book.textPath)
+ viewModelScope.launch {
+ cancelJobs()
+ joinJobs()
- if (text.isEmpty()) {
- event.onTextIsEmpty()
- }
+ launch(Dispatchers.IO) {
+ val text = getText.execute(_state.value.book.textPath)
- val textAsLine = text.joinToString(
- separator = "\n",
- transform = {
- it.line
+ if (text.isEmpty()) {
+ event.onTextIsEmpty()
}
- )
- val letters = textAsLine
- .replace("\n", "")
- .length
- val words = textAsLine
- .replace("\n", " ")
- .split("\\s+".toRegex())
- .size
-
- _state.update {
- it.copy(
- text = text,
- letters = letters,
- words = words
- )
- }
-
- val history = _state.value.book.id.let {
- getLatestHistory.execute(
- it
- )
- }
-
- _state.update {
- it.copy(
- book = it.book.copy(
- lastOpened = history?.time,
+ val textAsLine = text
+ .map { it.value.originalLine }
+ .joinToString(
+ separator = "\n"
)
+
+ val letters = textAsLine
+ .replace("\n", "")
+ .length
+ val words = textAsLine
+ .replace("\n", " ")
+ .split("\\s+".toRegex())
+ .size
+
+ _state.update {
+ it.copy(
+ text = text,
+ letters = letters,
+ words = words
+ )
+ }
+
+ val history = _state.value.book.id.let {
+ getLatestHistory.execute(
+ it
+ )
+ }
+
+ _state.update {
+ it.copy(
+ book = it.book.copy(
+ lastOpened = history?.time,
+ )
+ )
+ }
+
+ updateBooks.execute(
+ listOf(_state.value.book)
)
- }
+ event.refreshList(_state.value.book)
- updateBooks.execute(
- listOf(_state.value.book)
- )
- event.refreshList(_state.value.book)
+ viewModelScope.launch {
+ snapshotFlow {
+ _state.value.listState.layoutInfo.totalItemsCount
+ }.collectLatest { itemsCount ->
+ val index = _state.value.book.scrollIndex
+ val offset = _state.value.book.scrollOffset
- viewModelScope.launch {
- snapshotFlow {
- event.listState.layoutInfo.totalItemsCount
- }.collectLatest { itemsCount ->
- val index = _state.value.book.scrollIndex
- val offset = _state.value.book.scrollOffset
+ if (itemsCount >= _state.value.text.size) {
+ if (index > 0 || offset > 0) {
+ var loaded = false
+ for (i in 1..100) {
+ try {
+ _state.value.listState.scrollToItem(
+ index,
+ offset
+ )
+ loaded = true
+ break
+ } catch (e: Exception) {
+ Log.w(
+ "READER",
+ "Couldn't scroll to desired index and offset"
+ )
+ delay(100)
+ }
+ }
- if (itemsCount >= _state.value.text.size) {
- if (index > 0 || offset > 0) {
- var loaded = false
- for (i in 1..100) {
- try {
- event.listState.scrollToItem(
- index,
- offset
- )
- loaded = true
- break
- } catch (e: Exception) {
- Log.w(
- "READER",
- "Couldn't scroll to desired index and offset"
- )
- delay(100)
+ if (!loaded) {
+ event.onTextIsEmpty()
}
}
- if (!loaded) {
- event.onTextIsEmpty()
- }
- }
+ delay(100)
+ event.onLoaded()
- delay(100)
- event.onLoaded()
- return@collectLatest
+ if (
+ _state.value.book.translateWhenOpen &&
+ _state.value.book.enableTranslator
+ ) {
+ onEvent(
+ ReaderEvent.OnTranslateText(null) {
+ event.onError(it)
+ }
+ )
+ }
+
+ return@collectLatest
+ }
}
}
}
@@ -176,8 +226,648 @@ class ReaderViewModel @Inject constructor(
}
}
+ is ReaderEvent.OnShowHideTranslatorBottomSheet -> {
+ _state.update {
+ it.copy(
+ showTranslatorBottomSheet = event.show ?: !it.showTranslatorBottomSheet
+ )
+ }
+ }
+
+ is ReaderEvent.OnChangeTranslatorSettings -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ val book = _state.value.book
+ val updatedBook = book.copy(
+ enableTranslator = event.enableTranslator ?: book.enableTranslator,
+ translateFrom = event.translateFrom ?: book.translateFrom,
+ translateTo = event.translateTo ?: book.translateTo,
+ doubleClickTranslation = event.doubleClickTranslation
+ ?: book.doubleClickTranslation,
+ translateWhenOpen = event.translateWhenOpen ?: book.translateWhenOpen
+ )
+
+ _state.update {
+ it.copy(
+ book = updatedBook
+ )
+ }
+ updateBooks.execute(listOf(updatedBook))
+ }
+ }
+
+ is ReaderEvent.OnShowHideLanguageBottomSheet -> {
+ _state.update {
+ it.copy(
+ showLanguageBottomSheet = event.show ?: !it.showLanguageBottomSheet,
+ languageBottomSheetTranslateFrom = event.translateFrom ?: false
+ )
+ }
+ }
+
+ is ReaderEvent.OnTranslateText -> {
+ _state.update {
+ it.copy(
+ isTranslating = true
+ )
+ }
+
+ viewModelScope.launch {
+ joinJobs()
+
+ translatorJob = launch(Dispatchers.IO) {
+ onEvent(
+ ReaderEvent.OnChangeTranslatorSettings(
+ enableTranslator = true
+ )
+ )
+
+ val keys = event.keys ?: _state.value.text.keys
+ var linesToTranslate = if (keys.size >= _state.value.text.size) {
+ _state.value.text
+ } else {
+ _state.value.text.filterKeys { keys.any { key -> key == it } }
+ }
+
+ val languages = mutableMapOf?>()
+
+ yield()
+ linesToTranslate = linesToTranslate.updateWithCopy {
+ it.copy(
+ isTranslationLoading = true,
+ isTranslationFailed = false,
+ errorMessage = null,
+ useTranslation = false
+ )
+ }
+
+ yield()
+ onUpdateTextLines(
+ linesToTranslate,
+ wholeText = linesToTranslate.size >= _state.value.text.size
+ )
+
+ yield()
+ if (_state.value.book.translateFrom == "auto") {
+ val identifiedLanguages = mutableListOf>()
+ val errors = mutableMapOf()
+
+ for (line in linesToTranslate.toMap()) {
+ yield()
+ val result = identifyLanguage.execute(line.value.originalLine)
+
+ when (result) {
+ is Resource.Success -> {
+ identifiedLanguages.add(
+ result.data!! to line.key
+ )
+ }
+
+ is Resource.Error -> {
+ errors[line.key] = line.value.copy(
+ isTranslationFailed = true,
+ isTranslationLoading = false,
+ errorMessage = result.message
+ )
+ }
+ }
+ }
+
+ yield()
+ if (errors.isNotEmpty()) {
+ onUpdateTextLines(
+ errors,
+ errors.size >= _state.value.text.size
+ )
+ linesToTranslate = linesToTranslate.removeKeys(errors.keys)
+ }
+
+ yield()
+ languages.putAll(
+ identifiedLanguages
+ .groupBy {
+ it.first
+ }.mapValues { (_, value) ->
+ value.map { it.second }
+ }
+ )
+ } else {
+ languages[_state.value.book.translateFrom] = emptyList()
+ }
+
+ yield()
+ if (languages.isEmpty()) {
+ _state.update {
+ it.copy(
+ isTranslating = false
+ )
+ }
+
+ yield()
+ launch(Dispatchers.Main) {
+ event.error(UIText.StringResource(R.string.error_languages_not_identified))
+ }
+ return@launch
+ }
+
+ var shouldShowDialog = false
+
+ if (languages.any { !isLanguageModelDownloaded.execute(it.key) }) {
+ shouldShowDialog = true
+ }
+
+ if (!isLanguageModelDownloaded.execute(_state.value.book.translateTo)) {
+ shouldShowDialog = true
+ }
+
+ yield()
+ if (shouldShowDialog) {
+ val languagesToDownload = mutableListOf()
+
+ val translateToLocale = Locale(_state.value.book.translateTo)
+ val translateToDisplayLanguage = translateToLocale
+ .getDisplayLanguage(translateToLocale)
+ .replaceFirstChar {
+ it.uppercase()
+ }
+
+ val translateToCanceled = _state.value.languagesToTranslate.find {
+ it.languageCode == _state.value.book.translateTo
+ }?.isCanceled ?: false
+
+ languagesToDownload.add(
+ SelectableLanguage(
+ languageCode = _state.value.book.translateTo,
+ displayLanguage = translateToDisplayLanguage,
+ isDownloading = false,
+ isCanceled = translateToCanceled,
+ isError = false,
+ isDownloaded = isLanguageModelDownloaded.execute(_state.value.book.translateTo),
+ isSelected = true,
+ canUnselect = false,
+ occurrences = languages[_state.value.book.translateTo]
+ )
+ )
+ languagesToDownload.addAll(
+ languages
+ .filterNot { it.key == _state.value.book.translateTo }
+ .toList()
+ .sortedByDescending { it.second?.size }
+ .mapIndexed { index, language ->
+ val locale = Locale(language.first)
+ val displayLanguage = locale
+ .getDisplayLanguage(locale)
+ .replaceFirstChar {
+ it.uppercase()
+ }
+
+ val isCanceled = _state.value.languagesToTranslate.find {
+ it.languageCode == language.first
+ }?.isCanceled ?: false
+
+ SelectableLanguage(
+ languageCode = language.first,
+ displayLanguage = displayLanguage,
+ isDownloading = false,
+ isCanceled = isCanceled,
+ isDownloaded = isLanguageModelDownloaded.execute(
+ language.first
+ ),
+ isError = false,
+ isSelected = index == 0,
+ canUnselect = index > 0,
+ occurrences = language.second
+ )
+ }
+ )
+
+ yield()
+ _state.update {
+ it.copy(
+ languagesToTranslate = languagesToDownload,
+ showDownloadLanguageDialog = true
+ )
+ }
+ return@launch
+ }
+
+ yield()
+ for (i in 0.. result.data!!
+ is Resource.Error -> continue
+ }
+ } else {
+ _state.value.book.translateFrom
+ }
+ val targetLanguage = _state.value.book.translateTo
+
+ yield()
+ if (
+ line.translatingFrom == sourceLanguage &&
+ line.translatingTo == targetLanguage &&
+ line.translatedLine != null
+ ) {
+ val translatedLine = line.copy(
+ useTranslation = true,
+ isTranslationFailed = false,
+ isTranslationLoading = false,
+ errorMessage = null
+ )
+
+ yield()
+ _state.update {
+ it.copy(
+ text = it.text.update(
+ closestLine to translatedLine
+ )
+ )
+ }
+ linesToTranslate = linesToTranslate.removeKey(closestLine)
+ continue
+ }
+
+ var translation: String? = null
+ var error: UIText? = null
+
+ yield()
+ val result = translateText.execute(
+ sourceLanguage = sourceLanguage,
+ targetLanguage = targetLanguage,
+ text = line.originalLine
+ )
+
+ when (result) {
+ is Resource.Success -> {
+ translation = result.data
+ }
+
+ is Resource.Error -> {
+ error = result.message
+ }
+ }
+
+ val translatedLine = line.copy(
+ useTranslation = translation != null,
+ translatedLine = translation,
+ translatingFrom = sourceLanguage,
+ translatingTo = targetLanguage,
+ isTranslationFailed = translation == null,
+ isTranslationLoading = false,
+ errorMessage = error
+ )
+
+ yield()
+ _state.update {
+ it.copy(
+ text = it.text.update(
+ closestLine to translatedLine
+ )
+ )
+ }
+ linesToTranslate = linesToTranslate.removeKey(closestLine)
+ }
+
+ yield()
+ _state.update {
+ it.copy(
+ isTranslating = false
+ )
+ }
+ }
+ }
+ }
+
+ is ReaderEvent.OnDismissDownloadLanguageDialog -> {
+ _state.update {
+ it.copy(
+ showDownloadLanguageDialog = false,
+ isTranslating = false
+ )
+ }
+
+ viewModelScope.launch {
+ joinJobs()
+
+ dismissTranslatorJob = launch(Dispatchers.IO) {
+ val languagesToDownload = _state.value.languagesToTranslate
+
+ yield()
+ _state.update {
+ it.copy(
+ languagesToTranslate = it.languagesToTranslate.map { lang ->
+ lang.copy(
+ isCanceled = true,
+ isDownloading = false,
+ isError = false
+ )
+ },
+ showDownloadLanguageDialog = false
+ )
+ }
+
+ yield()
+
+ downloadLanguageJob?.cancel()
+ val lines = getLinesFromLanguages(languagesToDownload)
+ onUpdateTextLines(
+ lines.updateWithCopy {
+ it.copy(
+ isTranslationLoading = false,
+ useTranslation = false
+ )
+ },
+ wholeText = lines.size >= _state.value.text.size
+ )
+ }
+ }
+ }
+
+ is ReaderEvent.OnCancelTranslation -> {
+ viewModelScope.launch {
+ cancelTranslationJob?.join()
+ undoTranslationJob?.join()
+ dismissTranslatorJob?.cancel()
+ translatorJob?.cancel()
+
+ cancelTranslationJob = launch(Dispatchers.IO) {
+ _state.update {
+ it.copy(
+ isTranslating = false,
+ showDownloadLanguageDialog = false
+ )
+ }
+
+ yield()
+ onUpdateTextLines(
+ _state.value.text.updateWithCopy {
+ if (it.useTranslation) {
+ return@updateWithCopy it
+ }
+
+ it.copy(
+ isTranslationLoading = false,
+ useTranslation = false
+ )
+ },
+ wholeText = true
+ )
+ }
+ }
+ }
+
+ is ReaderEvent.OnUndoTranslation -> {
+ viewModelScope.launch {
+ cancelTranslationJob?.join()
+ undoTranslationJob?.join()
+
+ cancelTranslationJob = launch(Dispatchers.IO) {
+ yield()
+
+ if (event.id != null) {
+ _state.update {
+ val line = it.text[event.id]?.copy(
+ useTranslation = false,
+ isTranslationLoading = false
+ )
+
+ if (line == null) {
+ return@update it
+ }
+
+ it.copy(
+ text = it.text.update(event.id to line)
+ )
+ }
+ } else {
+ _state.update {
+ it.copy(
+ text = it.text.updateWithCopy { line ->
+ if (!line.useTranslation) {
+ return@updateWithCopy line
+ }
+
+ line.copy(
+ isTranslationLoading = false,
+ useTranslation = false
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ is ReaderEvent.OnDownloadLanguages -> {
+ viewModelScope.launch {
+ downloadLanguageJob?.cancel()
+
+ downloadLanguageJob = launch(Dispatchers.IO) {
+ _state.update {
+ it.copy(
+ languagesToTranslate = it.languagesToTranslate.map { lang ->
+ val isDownloaded = isLanguageModelDownloaded.execute(
+ lang.languageCode
+ )
+
+ lang.copy(
+ isDownloading = !isDownloaded && lang.isSelected,
+ isCanceled = false,
+ isDownloaded = isDownloaded
+ )
+ }
+ )
+ }
+
+ val shouldDownload = _state.value.languagesToTranslate.filter {
+ it.isSelected
+ }.any {
+ it.isDownloading
+ }
+
+ if (!shouldDownload) {
+ _state.update {
+ it.copy(
+ languagesToTranslate = it.languagesToTranslate.filter { lang ->
+ lang.isDownloaded
+ },
+ showDownloadLanguageDialog = false
+ )
+ }
+
+ onUnselectLines()
+ launch(Dispatchers.Main) {
+ event.onSuccess(getLinesFromLanguages(_state.value.languagesToTranslate))
+ }
+ return@launch
+ }
+
+ val languagesToDownload = _state.value.languagesToTranslate.filter {
+ it.isSelected
+ }
+ val jobs = languagesToDownload.map { lang ->
+ async {
+ downloadLanguageModel.execute(
+ languageCode = lang.languageCode,
+ onCompleted = {
+ launch(Dispatchers.IO) {
+ val modelDownloaded = isLanguageModelDownloaded.execute(
+ lang.languageCode
+ )
+
+ if (!modelDownloaded) {
+ onEvent(
+ ReaderEvent.OnUpdateLanguage(lang.languageCode) {
+ it.copy(
+ isError = true,
+ isDownloading = false,
+ isDownloaded = false,
+ errorMessage = UIText.StringResource(
+ R.string.error_could_not_download_language
+ )
+ )
+ }
+ )
+ return@launch
+ }
+
+ onEvent(
+ ReaderEvent.OnUpdateLanguage(lang.languageCode) {
+ it.copy(
+ isDownloading = false,
+ isDownloaded = true
+ )
+ }
+ )
+
+ val isCanceled =
+ _state.value.languagesToTranslate.find {
+ it.languageCode == lang.languageCode
+ }?.isCanceled ?: true
+
+ if (isCanceled) {
+ deleteLanguageModel.execute(
+ languageCode = lang.languageCode
+ )
+ onEvent(
+ ReaderEvent.OnUpdateLanguage(lang.languageCode) {
+ it.copy(
+ isDownloading = false,
+ isDownloaded = false,
+ isCanceled = false
+ )
+ }
+ )
+ return@launch
+ }
+ }
+ },
+ onFailure = { error ->
+ onEvent(
+ ReaderEvent.OnUpdateLanguage(lang.languageCode) {
+ it.copy(
+ isError = true,
+ isDownloading = false,
+ isDownloaded = false,
+ errorMessage = UIText.StringResource(
+ R.string.error_query,
+ error.message ?: ""
+ )
+ )
+ }
+ )
+ }
+ )
+ }
+ }
+ jobs.awaitAll()
+
+ yield()
+
+ _state.update {
+ it.copy(
+ languagesToTranslate = it.languagesToTranslate.filter { lang ->
+ lang.isDownloaded
+ },
+ showDownloadLanguageDialog = false
+ )
+ }
+
+ if (languagesToDownload.isEmpty()) {
+ yield()
+ launch(Dispatchers.Main) {
+ event.error(
+ UIText.StringResource(
+ R.string.error_could_not_download_languages
+ )
+ )
+ }
+ onEvent(ReaderEvent.OnDismissDownloadLanguageDialog)
+ return@launch
+ }
+
+ yield()
+
+ onUnselectLines()
+ launch(Dispatchers.Main) {
+ event.onSuccess(getLinesFromLanguages(_state.value.languagesToTranslate))
+ }
+ }
+ }
+ }
+
+ is ReaderEvent.OnSelectLanguage -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ val editedList = _state.value.languagesToTranslate.map {
+ if (it.languageCode == event.language.languageCode) {
+ it.copy(
+ isSelected = !it.isSelected
+ )
+ } else {
+ it
+ }
+ }
+
+ _state.update {
+ it.copy(
+ languagesToTranslate = editedList
+ )
+ }
+ }
+ }
+
+ is ReaderEvent.OnUpdateLanguage -> {
+ viewModelScope.launch(Dispatchers.IO) {
+ val editedList = _state.value.languagesToTranslate.map {
+ if (it.languageCode == event.languageCode) {
+ event.calculation(it)
+ } else {
+ it
+ }
+ }
+
+ _state.update {
+ it.copy(
+ languagesToTranslate = editedList
+ )
+ }
+ }
+ }
+
is ReaderEvent.OnGoBack -> {
viewModelScope.launch {
+ cancelJobs()
_state.update {
it.copy(
lockMenu = true
@@ -189,17 +879,18 @@ class ReaderViewModel @Inject constructor(
event.context.window.decorView
)
- if (event.listState.layoutInfo.totalItemsCount > 0) {
- val firstVisibleItemIndex = event.listState.firstVisibleItemIndex
- val firstVisibleItemOffset = event.listState.firstVisibleItemScrollOffset
- val lastVisibleItemIndex = event.listState.layoutInfo
+ if (_state.value.listState.layoutInfo.totalItemsCount > 0) {
+ val firstVisibleItemIndex = _state.value.listState.firstVisibleItemIndex
+ val firstVisibleItemOffset =
+ _state.value.listState.firstVisibleItemScrollOffset
+ val lastVisibleItemIndex = _state.value.listState.layoutInfo
.visibleItemsInfo.last().index
val progress = if (firstVisibleItemIndex > 0) {
- if (lastVisibleItemIndex >= (event.listState.layoutInfo.totalItemsCount - 1)) {
+ if (lastVisibleItemIndex >= (_state.value.listState.layoutInfo.totalItemsCount - 1)) {
1f
} else {
- (firstVisibleItemIndex.toFloat() / (_state.value.text.lastIndex)
+ (firstVisibleItemIndex.toFloat() / (_state.value.text.size - 1)
.toFloat())
}
} else {
@@ -224,7 +915,6 @@ class ReaderViewModel @Inject constructor(
Argument("book", _state.value.book.id)
)
event.refreshList(_state.value.book)
-
insetsController.show(WindowInsetsCompat.Type.systemBars())
event.navigate(event.navigator)
}
@@ -234,7 +924,7 @@ class ReaderViewModel @Inject constructor(
viewModelScope.launch {
val scrollTo = (_state.value.text.size * event.progress).roundToInt()
- event.listState.scrollToItem(
+ _state.value.listState.scrollToItem(
scrollTo
)
}
@@ -288,14 +978,13 @@ class ReaderViewModel @Inject constructor(
ReaderEvent.OnGoBack(
event.context,
event.navigator,
- event.listState,
refreshList = {},
navigate = {}
)
)
- val firstVisibleItemIndex = event.listState.firstVisibleItemIndex
- val firstVisibleItemOffset = event.listState.firstVisibleItemScrollOffset
+ val firstVisibleItemIndex = _state.value.listState.firstVisibleItemIndex
+ val firstVisibleItemOffset = _state.value.listState.firstVisibleItemScrollOffset
_state.update {
it.copy(
@@ -317,11 +1006,14 @@ class ReaderViewModel @Inject constructor(
it != Category.ALREADY_READ
}.size - 1
)
+
+ cancelJobs()
+
event.navigator.navigate(Screen.LIBRARY, true)
}
}
- is ReaderEvent.OnTranslateText -> {
+ is ReaderEvent.OnOpenTranslator -> {
viewModelScope.launch {
val translatorIntent = Intent()
val browserIntent = Intent()
@@ -375,11 +1067,188 @@ class ReaderViewModel @Inject constructor(
}
}
+ private fun cancelJobs() {
+ cancelTranslationJob?.cancel()
+ translatorJob?.cancel()
+ dismissTranslatorJob?.cancel()
+ undoTranslationJob?.cancel()
+ downloadLanguageJob?.cancel()
+ }
+
+ private suspend fun joinJobs() {
+ cancelTranslationJob?.join()
+ translatorJob?.join()
+ dismissTranslatorJob?.join()
+ undoTranslationJob?.join()
+ }
+
+
+ private suspend fun findClosestLineToTranslate(
+ translatingLines: Map
+ ): ID? {
+ val lines = translatingLines.toMutableMap()
+ val visibleItems = _state.value.listState.layoutInfo.visibleItemsInfo
+ val listStateIndex = (visibleItems.getOrNull(visibleItems.size / 2)?.index ?: 0) - 1
+ .coerceAtLeast(0)
+
+ var closestLineIndex: Int? = null
+ var searchUpperLine = false
+ var currentLineIndex = listStateIndex
+ val invalidIndices = mutableListOf()
+
+ while (true) {
+ yield()
+
+ if (lines.isEmpty()) {
+ break
+ }
+
+ currentLineIndex = currentLineIndex.coerceIn(0..lines.maxOf { it.key })
+
+ if (lines[currentLineIndex] == null) {
+ invalidIndices.add(currentLineIndex)
+
+ if (searchUpperLine) {
+ val invalid = invalidIndices.filterNot {
+ it >= currentLineIndex
+ }
+ var minus = 1
+
+ while (true) {
+ if (invalid.any { currentLineIndex - minus == it }) {
+ minus++
+ continue
+ }
+ break
+ }
+
+ currentLineIndex -= minus
+ } else {
+ val invalid = invalidIndices.filterNot {
+ it <= currentLineIndex
+ }
+ var plus = 1
+
+ while (true) {
+ if (invalid.any { currentLineIndex + plus == it }) {
+ plus++
+ continue
+ }
+ break
+ }
+
+ currentLineIndex += plus
+ }
+ searchUpperLine = !searchUpperLine
+ continue
+ }
+
+ if (
+ lines[currentLineIndex]?.useTranslation == true ||
+ lines[currentLineIndex]?.isTranslationFailed == true
+ ) {
+ invalidIndices.add(currentLineIndex)
+ lines.remove(currentLineIndex)
+
+ if (searchUpperLine) {
+ val invalid = invalidIndices.filterNot {
+ it >= currentLineIndex
+ }
+ var minus = 1
+
+ while (true) {
+ if (invalid.any { currentLineIndex - minus == it }) {
+ minus++
+ continue
+ }
+ break
+ }
+
+ currentLineIndex -= minus
+ } else {
+ val invalid = invalidIndices.filterNot {
+ it <= currentLineIndex
+ }
+ var plus = 1
+
+ while (true) {
+ if (invalid.any { currentLineIndex + plus == it }) {
+ plus++
+ continue
+ }
+ break
+ }
+
+ currentLineIndex += plus
+ }
+ searchUpperLine = !searchUpperLine
+ continue
+ }
+
+ closestLineIndex = currentLineIndex
+ break
+ }
+
+ yield()
+ return closestLineIndex
+ }
+
+ private suspend fun onUpdateTextLines(
+ newLines: Map,
+ wholeText: Boolean
+ ) {
+ val newText = if (wholeText) newLines
+ else _state.value.text.update(newLines)
+
+ yield()
+ _state.update {
+ it.copy(
+ text = newText
+ )
+ }
+ }
+
+ private suspend fun onUnselectLines() {
+ onUpdateTextLines(
+ _state.value.text.updateWithCopy {
+ it.copy(isTranslationLoading = false)
+ },
+ wholeText = true
+ )
+ }
+
+ private suspend fun getLinesFromLanguages(
+ languages: List
+ ): Map {
+ val lines = mutableMapOf()
+
+ yield()
+ for (language in languages) {
+ yield()
+
+ if (language.occurrences == null) {
+ continue
+ }
+
+ if (language.occurrences.isEmpty()) {
+ lines += _state.value.text
+ break
+ }
+
+ lines += language.occurrences.associateWith {
+ _state.value.text[it]!!
+ }
+ }
+ yield()
+
+ return lines
+ }
+
fun init(
navigator: Navigator,
context: ComponentActivity,
- listState: LazyListState,
refreshList: (Book) -> Unit,
+ onError: (UIText) -> Unit,
onLoaded: () -> Unit
) {
viewModelScope.launch(Dispatchers.IO) {
@@ -408,10 +1277,12 @@ class ReaderViewModel @Inject constructor(
onEvent(
ReaderEvent.OnLoadText(
refreshList = { refreshList(it) },
- listState = listState,
onLoaded = {
onLoaded()
},
+ onError = {
+ onError(it)
+ },
onTextIsEmpty = {
onEvent(ReaderEvent.OnTextIsEmpty(onLoaded = { onLoaded() }))
}
@@ -419,17 +1290,52 @@ class ReaderViewModel @Inject constructor(
)
}
}
-}
-
-
-
-
-
-
-
-
-
-
+ @OptIn(FlowPreview::class)
+ suspend fun onUpdateProgress(
+ firstVisibleItemIndex: Int,
+ firstVisibleItemOffset: Int,
+ navigator: Navigator,
+ isReaderLoading: Boolean,
+ onLibraryEvent: (LibraryEvent) -> Unit,
+ onHistoryEvent: (HistoryEvent) -> Unit
+ ) {
+ snapshotFlow {
+ firstVisibleItemIndex to firstVisibleItemOffset
+ }.debounce(500).collectLatest { items ->
+ if (
+ !isReaderLoading &&
+ _state.value.text.isNotEmpty() &&
+ _state.value.listState.layoutInfo.totalItemsCount > 0
+ ) {
+ val lastVisibleItemIndex = _state.value.listState.layoutInfo.visibleItemsInfo
+ .last().index
+ val progress = if (items.first > 0) {
+ if (
+ lastVisibleItemIndex >= (_state.value.listState.layoutInfo.totalItemsCount - 1)
+ ) {
+ 1f
+ } else {
+ (items.first.toFloat() / (_state.value.text.size - 1).toFloat())
+ }
+ } else {
+ 0f
+ }
+ onEvent(
+ ReaderEvent.OnChangeProgress(
+ progress = progress,
+ navigator = navigator,
+ firstVisibleItemIndex = items.first,
+ firstVisibleItemOffset = items.second,
+ refreshList = { book ->
+ onLibraryEvent(LibraryEvent.OnUpdateBook(book))
+ onHistoryEvent(HistoryEvent.OnUpdateBook(book))
+ }
+ )
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsEvent.kt
index c9bfa87f..a32c8e28 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsEvent.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsEvent.kt
@@ -15,4 +15,9 @@ sealed class SettingsEvent {
val notificationsPermissionState: PermissionState,
val onChangeCheckForUpdates: (Boolean) -> Unit
) : SettingsEvent()
+
+ data class OnReaderShowHideTranslatorLanguageBottomSheet(
+ val show: Boolean? = null,
+ val translateFrom: Boolean? = null
+ ) : SettingsEvent()
}
\ No newline at end of file
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsState.kt
new file mode 100644
index 00000000..91c6fc42
--- /dev/null
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsState.kt
@@ -0,0 +1,9 @@
+package ua.acclorite.book_story.presentation.screens.settings.data
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+data class SettingsState(
+ val showReaderTranslatorLanguageBottomSheet: Boolean = false,
+ val readerTranslatorLanguageBottomSheetTranslateFrom: Boolean = false,
+)
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsViewModel.kt
index 58e802dd..7c44c99c 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsViewModel.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsViewModel.kt
@@ -11,6 +11,9 @@ import com.google.accompanist.permissions.shouldShowRationale
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import javax.inject.Inject
@@ -21,6 +24,9 @@ class SettingsViewModel @Inject constructor(
) : ViewModel() {
+ private val _state = MutableStateFlow(SettingsState())
+ val state = _state.asStateFlow()
+
private var notificationsPermissionJob: Job? = null
fun onEvent(event: SettingsEvent) {
@@ -71,6 +77,17 @@ class SettingsViewModel @Inject constructor(
}
}
}
+
+ is SettingsEvent.OnReaderShowHideTranslatorLanguageBottomSheet -> {
+ _state.update {
+ it.copy(
+ showReaderTranslatorLanguageBottomSheet = event.show
+ ?: !it.showReaderTranslatorLanguageBottomSheet,
+ readerTranslatorLanguageBottomSheetTranslateFrom = event.translateFrom
+ ?: false
+ )
+ }
+ }
}
}
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/appearance/components/theme_switcher/AppearanceSettingsThemeSwitcherItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/appearance/components/theme_switcher/AppearanceSettingsThemeSwitcherItem.kt
index f86179d1..3c6f44b0 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/appearance/components/theme_switcher/AppearanceSettingsThemeSwitcherItem.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/appearance/components/theme_switcher/AppearanceSettingsThemeSwitcherItem.kt
@@ -39,7 +39,14 @@ import ua.acclorite.book_story.presentation.ui.ThemeContrast
import ua.acclorite.book_story.presentation.ui.animatedColorScheme
/**
- * Theme switcher item.
+ * Theme switcher item. Used to change app theme.
+ *
+ * @param theme Target [Theme].
+ * @param darkTheme Whether should be shown in Dark Theme.
+ * @param isPureDark Whether should be shown in Pure Dark.
+ * @param themeContrast [ThemeContrast].
+ * @param selected True if this theme is currently selected.
+ * @param onClick OnClick callback.
*/
@Composable
fun AppearanceSettingsThemeSwitcherItem(
@@ -69,7 +76,8 @@ fun AppearanceSettingsThemeSwitcherItem(
onClick()
}
.background(
- colorScheme.surface
+ colorScheme.surface,
+ MaterialTheme.shapes.large
)
.border(
4.dp,
@@ -87,10 +95,9 @@ fun AppearanceSettingsThemeSwitcherItem(
) {
Box(
Modifier
- .clip(RoundedCornerShape(10.dp))
.height(20.dp)
.width(80.dp)
- .background(colorScheme.onSurface)
+ .background(colorScheme.onSurface, RoundedCornerShape(10.dp))
)
Spacer(modifier = Modifier.width(8.dp))
@@ -117,9 +124,9 @@ fun AppearanceSettingsThemeSwitcherItem(
.padding(start = 8.dp)
.height(80.dp)
.width(70.dp)
- .clip(RoundedCornerShape(14.dp))
.background(
- colorScheme.surfaceContainer
+ colorScheme.surfaceContainer,
+ RoundedCornerShape(14.dp)
)
)
Spacer(modifier = Modifier.height(40.dp))
@@ -135,16 +142,14 @@ fun AppearanceSettingsThemeSwitcherItem(
) {
Box(
Modifier
- .clip(CircleShape)
.size(20.dp)
- .background(colorScheme.primary)
+ .background(colorScheme.primary, CircleShape)
)
Box(
Modifier
- .clip(RoundedCornerShape(10.dp))
.height(20.dp)
.width(60.dp)
- .background(colorScheme.onSurfaceVariant)
+ .background(colorScheme.onSurfaceVariant, RoundedCornerShape(10.dp))
)
}
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/reader/ReaderSettings.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/reader/ReaderSettings.kt
index d4fa290c..29d9f441 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/reader/ReaderSettings.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/reader/ReaderSettings.kt
@@ -20,6 +20,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
@@ -30,26 +31,36 @@ import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.presentation.components.CategoryTitle
import ua.acclorite.book_story.presentation.components.GoBackButton
import ua.acclorite.book_story.presentation.components.collapsibleUntilExitScrollBehaviorWithLazyListState
+import ua.acclorite.book_story.presentation.components.translator_language.TranslatorLanguageBottomSheet
import ua.acclorite.book_story.presentation.data.LocalNavigator
import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.data.MainViewModel
import ua.acclorite.book_story.presentation.data.Navigator
+import ua.acclorite.book_story.presentation.screens.reader.components.translator_bottom_sheet.ReaderTranslatorBottomSheetLanguageChooser
import ua.acclorite.book_story.presentation.screens.settings.components.ChipsWithTitle
import ua.acclorite.book_story.presentation.screens.settings.components.SliderWithTitle
import ua.acclorite.book_story.presentation.screens.settings.components.SwitchWithTitle
+import ua.acclorite.book_story.presentation.screens.settings.data.SettingsEvent
+import ua.acclorite.book_story.presentation.screens.settings.data.SettingsState
+import ua.acclorite.book_story.presentation.screens.settings.data.SettingsViewModel
+import java.util.Locale
@Composable
fun ReaderSettingsRoot() {
val navigator = LocalNavigator.current
val mainViewModel: MainViewModel = hiltViewModel()
+ val settingsViewModel: SettingsViewModel = hiltViewModel()
val state = mainViewModel.state.collectAsState()
+ val settingsState = settingsViewModel.state.collectAsState()
ReaderSettings(
state = state,
+ settingsState = settingsState,
navigator = navigator,
- onMainEvent = mainViewModel::onEvent
+ onMainEvent = mainViewModel::onEvent,
+ onEvent = settingsViewModel::onEvent
)
}
@@ -57,16 +68,70 @@ fun ReaderSettingsRoot() {
@Composable
private fun ReaderSettings(
state: State,
+ settingsState: State,
navigator: Navigator,
- onMainEvent: (MainEvent) -> Unit
+ onMainEvent: (MainEvent) -> Unit,
+ onEvent: (SettingsEvent) -> Unit
) {
val scrollState = TopAppBarDefaults.collapsibleUntilExitScrollBehaviorWithLazyListState()
+ val context = LocalContext.current
val fontFamily = remember(state.value.fontFamily) {
Constants.FONTS.find {
it.id == state.value.fontFamily
} ?: Constants.FONTS[0]
}
+ val translateFrom = remember(state.value.translateFrom) {
+ if (state.value.translateFrom!! == "auto") {
+ return@remember context.getString(R.string.translator_auto_short)
+ }
+
+ val locale = Locale(state.value.translateFrom!!)
+ locale.getDisplayName(locale).replaceFirstChar { char ->
+ char.uppercase()
+ }
+ }
+ val translateTo = remember(state.value.translateTo) {
+ val locale = Locale(state.value.translateTo!!)
+ locale.getDisplayName(locale).replaceFirstChar { char ->
+ char.uppercase()
+ }
+ }
+
+ if (settingsState.value.showReaderTranslatorLanguageBottomSheet) {
+ TranslatorLanguageBottomSheet(
+ selectedLanguage = if (
+ settingsState.value.readerTranslatorLanguageBottomSheetTranslateFrom
+ ) {
+ state.value.translateFrom!!
+ } else state.value.translateTo!!,
+
+ unselectedLanguage = if (
+ !settingsState.value.readerTranslatorLanguageBottomSheetTranslateFrom
+ ) {
+ state.value.translateFrom!!
+ } else state.value.translateTo!!,
+
+ translateFromSelecting = settingsState.value.readerTranslatorLanguageBottomSheetTranslateFrom,
+ onSelect = { from, to ->
+ onMainEvent(MainEvent.OnChangeTranslateFrom(from))
+ onMainEvent(MainEvent.OnChangeTranslateTo(to))
+
+ onEvent(
+ SettingsEvent.OnReaderShowHideTranslatorLanguageBottomSheet(
+ show = false
+ )
+ )
+ },
+ onDismiss = {
+ onEvent(
+ SettingsEvent.OnReaderShowHideTranslatorLanguageBottomSheet(
+ show = false
+ )
+ )
+ }
+ )
+ }
Scaffold(
Modifier
@@ -99,6 +164,91 @@ private fun ReaderSettings(
item {
Spacer(modifier = Modifier.height(16.dp))
}
+ item {
+ CategoryTitle(
+ title = stringResource(id = R.string.translator_reader_settings),
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ item {
+ SwitchWithTitle(
+ selected = state.value.enableTranslator!!,
+ title = stringResource(id = R.string.translator_enable_default_option),
+ description = stringResource(id = R.string.translator_enable_default_option_desc),
+ onClick = {
+ onMainEvent(
+ MainEvent.OnChangeEnableTranslator(
+ !state.value.enableTranslator!!
+ )
+ )
+ }
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ CategoryTitle(
+ title = stringResource(id = R.string.translator_language_option)
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ ReaderTranslatorBottomSheetLanguageChooser(
+ fromLanguage = translateFrom,
+ toLanguage = translateTo,
+ onFromClick = {
+ onEvent(
+ SettingsEvent.OnReaderShowHideTranslatorLanguageBottomSheet(
+ show = true,
+ translateFrom = true
+ )
+ )
+ },
+ onToClick = {
+ onEvent(
+ SettingsEvent.OnReaderShowHideTranslatorLanguageBottomSheet(
+ show = true,
+ translateFrom = false
+ )
+ )
+ },
+ switchEnabled = state.value.translateFrom != "auto",
+ onSwitchClick = {
+ val from = state.value.translateFrom!!
+ val to = state.value.translateTo!!
+
+ onMainEvent(MainEvent.OnChangeTranslateFrom(to))
+ onMainEvent(MainEvent.OnChangeTranslateTo(from))
+ }
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+
+ item {
+ SwitchWithTitle(
+ selected = state.value.doubleClickTranslation!!,
+ title = stringResource(id = R.string.translator_double_click_to_translate_option),
+ description = stringResource(id = R.string.translator_double_click_to_translate_option_desc),
+ onClick = {
+ onMainEvent(
+ MainEvent.OnChangeDoubleClickTranslation(
+ !state.value.doubleClickTranslation!!
+ )
+ )
+ }
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(22.dp))
+ }
item {
CategoryTitle(
title = stringResource(id = R.string.font_reader_settings),
@@ -108,6 +258,7 @@ private fun ReaderSettings(
item {
Spacer(modifier = Modifier.height(8.dp))
}
+
item {
ChipsWithTitle(
title = stringResource(id = R.string.font_family_option),
diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/start/components/StartSettings.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/start/components/StartSettings.kt
index c66a3025..b7311ee3 100644
--- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/start/components/StartSettings.kt
+++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/start/components/StartSettings.kt
@@ -43,7 +43,14 @@ import ua.acclorite.book_story.presentation.screens.start.data.StartState
import ua.acclorite.book_story.presentation.ui.Theme
/**
- * Settings screen.
+ * Start Settings.
+ *
+ * @param state [StartState].
+ * @param mainState [MainState].
+ * @param onEvent [StartEvent] callback.
+ * @param onMainEvent [MainEvent] callback.
+ * @param storagePermissionState Storage [PermissionState].
+ * @param notificationsPermissionState Notifications [PermissionState].
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
@@ -123,7 +130,10 @@ fun StartSettings(
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.large)
- .background(MaterialTheme.colorScheme.surfaceContainerLow),
+ .background(
+ MaterialTheme.colorScheme.surfaceContainerLow,
+ MaterialTheme.shapes.large
+ ),
) {
StartNavigationScreenItem(
state = state,
diff --git a/app/src/main/res/drawable/empty_languages.png b/app/src/main/res/drawable/empty_languages.png
new file mode 100644
index 00000000..fadacf56
Binary files /dev/null and b/app/src/main/res/drawable/empty_languages.png differ
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 8ce27f8c..daa84e2d 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -36,6 +36,8 @@
Перемістити книгу?
Видалити історію читання?
Підтвердити оновлення?
+ Завантажити %1$s?
+ Завантажити мови?
@@ -78,6 +80,17 @@
Натиснувши на кнопку «Завантажити», ви перейдете в ваш браузер
та завантажете останнє оновлення автоматично.
+
+ Вам потрібно завантажити цю мову перед використанням.
+ Після завантаження, вона буде доступна офлайн та не потребуватиме інтернету.
+ Кожна мова має розмір близько ~30мб, для завантаження рекомендується використовувати Wi-Fi.
+
+
+ Вам потрібно завантажити ці мови перед використанням.
+ Кожна мова має розмір близько ~30мб, для завантаження рекомендується використовувати Wi-Fi.
+ Ви можете вибрати мови, які ви хочете завантажити,
+ всі не вибрані мови не будуть перекладені.
+
@@ -104,6 +117,12 @@
Щось пішло не так.
Спробуйте пізніше.
+ Мова не була ідентифікована.
+ Не вдалося ідентифікувати жодної мови.
+ Неможливо вибрати ту саму мову двічі.
+ Не можна вибрати цю мову.
+ Не вдалося завантажити мову.
+ Не вдалося завантажити мови.
@@ -116,6 +135,9 @@
Нехай ваша остання прочитана книга відкриє перед
вами сторінки нової великої історії вашого читацького шляху
+
+ Не існує мови з такою назвою, або вона не підтримується
+
%1$s обрано
@@ -150,11 +172,14 @@
Ні
Завантажити
Готово
+ Відмінити
+ Далі
файли
книги
історію
+ мови
Невідомий
@@ -181,13 +206,14 @@
Головні
Мова застосунка
Зовнішній вигляд
- Налаштування теми, кольори
+ Тема, кольори
Читач
- Налаштування шрифта, налаштування текста
+ Перекладач, шрифт, текст
Шрифт
Текст
+ Перекладач
Налаштування теми
Кольори
@@ -208,6 +234,13 @@
Перевіряти на оновлення
Перевіряти на новішу версію застосунку при запуску
Бокові відступи
+ Мова перекладача
+ Увімкнути перекладач
+ Увімкнути перекладач при додаванні книги за замовчуванням
+ Переклад подвійним кліком
+ Двічі клацніть на абзац, щоб перекласти його
+ Перекладати при відкритті книги
+ Автоматично перекладати весь текст при відкритті книги
Червоний
@@ -255,7 +288,12 @@
Ця книга була успішно переміщена.
Скопійовано
Історія була успішно видалена.
- Остання версія застосунку вже встановлена.
+ Остання версія застосунка вже встановлена.
+ Мова була успішно завантажена та вибрана.
+ Всі необхідні мови завантажені. Переклад почнеться незабаром.
+ Переклад відміняється. Будь ласка, зачекайте.
+ Перекладений текст відміняється. Будь ласка, зачекайте.
+ Переклад скоро почнеться. Будь ласка, зачекайте.
Елемент історії був успішно видалений.
@@ -282,14 +320,25 @@
Дякуємо за те, що прочитали \"%1$s\" з нами.
Ми сподіваємось, що вам сподобалася ця книга!
- %1$s букв, %2$s слів.
+ %1$s букв(а), %2$s слів.
+
+
+ Визначити мову
+ Визначити
+ Перекласти %1$s
+ з
+ на
+ Всі мови
+ Недавні мови
+ %1$s абзац(ів)
+ Показати оригінал (%1$s на %2$s)
- Версія застосунку
+ Версія застосунка
Версія v%1$s
Натисніть, щоб перевірити на оновлення
Повідомити про помилку
- Сторінка проєкту
+ Сторінка проєкта
Підтримати мою роботу
Ліцензії з відкритим кодом
Заслуги
@@ -474,6 +523,8 @@
Скинути початковий посібник
Вийти з редагування назви
Відкрити в браузері
-
+ Перекладач
+ Поміняти мови місцями
+ Завантажено
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 53c763a8..42cb001f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -55,6 +55,8 @@
Move book?
Delete reading history?
Confirm update?
+ Download %1$s?
+ Download languages?
@@ -97,6 +99,17 @@
By clicking the «Download» button you will be transferred
to your browser and download the latest update automatically.
+
+ You need to download this language before using it.
+ After downloading, it will be available offline and will not require internet.
+ Each language is about ~30mb in size, it is recommended to use Wi-Fi to download it.
+
+
+ You need to download these languages before.
+ Each language is about ~30mb in size, it is recommended to use Wi-Fi to download it.
+ You can select which languages you want to download,
+ all unselected languages will not be translated.
+
@@ -123,6 +136,12 @@
Something went wrong.
Try again later.
+ Could not identify language.
+ Could not identify any languages.
+ Cannot select same language twice.
+ Cannot select this language.
+ Could not download language.
+ Could not download languages.
@@ -134,6 +153,9 @@
Let your last book you read open the pages of a new great story of your reading journey
+
+ There is no such language with this name, or it is not supported
+
%1$s selected
@@ -168,11 +190,14 @@
No
Download
Done
+ Undo
+ Continue
files
books
history
+ languages
Unknown
@@ -199,13 +224,14 @@
General
App language
Appearance
- Theme preferences, colors
+ Theme, colors
Reader
- Font settings, text settings
+ Translator, font, text
Font
Text
+ Translator
Theme preferences
Colors
@@ -226,6 +252,13 @@
Check for updates
Check for newer app version on startup
Side padding
+ Translator language
+ Enable translator
+ Enable translator when adding a book by default
+ Double click translation
+ Double click on paragraph to translate it
+ Translate when open a book
+ Automatically translate the whole text when open a book
Red
@@ -274,6 +307,13 @@
Copied
History was successfully deleted.
The latest version of the app is already installed.
+ Language was successfully downloaded and selected.
+
+ All required languages are downloaded. Translation will start soon.
+
+ Translation is canceling. Please wait.
+ Translated text is canceling. Please wait.
+ Translation will start soon. Please wait.
History element was successfully deleted.
@@ -302,6 +342,17 @@
%1$s characters, %2$s words.
+
+ Detect language
+ Detect
+ Translate %1$s
+ from
+ to
+ All languages
+ Recent languages
+ %1$s paragraph(s)
+ Show original (%1$s to %2$s)
+
App version
Version v%1$s
@@ -493,5 +544,8 @@
Reset start guide
Exit title editing
Open in web
+ Translator
+ Change language places
+ Downloaded
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index b4e9c200..d09d2bba 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,7 +1,11 @@
plugins {
- id("com.android.application") version "8.2.0-rc03" apply false
- id("org.jetbrains.kotlin.android") version "1.9.21" apply false
- id("com.google.devtools.ksp") version "1.9.21-1.0.16" apply false
+ id("com.android.application") version "8.3.0" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.0" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" apply false
+ id("com.google.devtools.ksp") version "2.0.0-1.0.21" apply false
id("com.google.dagger.hilt.android") version "2.50" apply false
id("com.mikepenz.aboutlibraries.plugin") version "11.1.3" apply false
+ id("androidx.room") version "2.6.1" apply false
+ kotlin("jvm") version "2.0.0" apply true
+ kotlin("plugin.serialization") version "2.0.0" apply false
}
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 459be50b..e411586a 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,5 @@
-#Fri Dec 29 23:56:29 EET 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists