Built-in Translator + ModalBottomSheet → CustomBottomSheet(fixes bugs) + Constants.NAVIGATION_ITEMS for NavigationBars + Various dependencies updates + ReleaseResponse → LatestReleaseInfo + Fixed: Modifier.clip did not apply correctly + Room Version Migration(cause of translator) + Changed order of the selection toolbar items(revert): Translate, Copy, Dictionary > Copy, Translate, Dictionary + Gradle Version Update 8.2 → 8.4 + StringWithId → LineWithTranslation + Less transparent bars color in ReaderScreen + Small localization changes (v1.0.1)

This commit is contained in:
acclorite 2024-06-04 21:31:32 +03:00
parent b5d99916a1
commit 04e67aa03c
101 changed files with 5120 additions and 724 deletions

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.21" />
<option name="version" value="2.0.0" />
</component>
</project>

View file

@ -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")
}

View file

@ -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(

View file

@ -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()
)
}
}

View file

@ -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
)

View file

@ -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"

View file

@ -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))

View file

@ -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

View file

@ -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
)
}
}

View file

@ -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<List<StringWithId>>
suspend fun parse(file: File): Resource<List<String>>
}

View file

@ -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<List<StringWithId>> {
override suspend fun parse(file: File): Resource<List<String>> {
val fileFormat = ".${file.name.substringAfterLast(".")}"
if (fileFormat == ".pdf") {

View file

@ -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

View file

@ -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<List<StringWithId>> {
override suspend fun parse(file: File): Resource<List<String>> {
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<StringWithId>()
val strings = mutableListOf<String>()
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(

View file

@ -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

View file

@ -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<List<StringWithId>> {
override suspend fun parse(file: File): Resource<List<String>> {
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<StringWithId>()
val strings = mutableListOf<String>()
val pdfStripper = PDFTextStripper()
pdfStripper.paragraphStart = "</br>"
@ -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(

View file

@ -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

View file

@ -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<List<StringWithId>> {
override suspend fun parse(file: File): Resource<List<String>> {
if (!file.name.endsWith(".txt")) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
return try {
val formattedLines = mutableListOf<StringWithId>()
val formattedLines = mutableListOf<String>()
withContext(Dispatchers.IO) {
BufferedReader(FileReader(file)).forEachLine { line ->
if (line.isNotBlank()) {
formattedLines.add(
StringWithId(line)
line.trim()
)
}
}

View file

@ -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/"

View file

@ -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
)

View file

@ -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<StringWithId> {
override suspend fun getBookText(textPath: String): List<String> {
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<StringWithId>
text: List<String>
): 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<StringWithId>): Boolean {
override suspend fun updateBookWithText(book: Book, text: List<String>): 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<String> {
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<String> {
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
)
)
)
}
}
}
}

View file

@ -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
)

View file

@ -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<ID>?
)

View file

@ -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

View file

@ -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

View file

@ -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
)

View file

@ -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<StringWithId> = emptyList(),
val text: List<String> = emptyList(),
val fileName: String?,
val message: UIText?
) {
class NotNull(
book: Book,
coverImage: CoverImage?,
text: List<StringWithId>
text: List<String>
) : NullableBook(
book = book,
text = text,

View file

@ -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<StringWithId>
): List<String>
suspend fun insertBook(
book: Book,
coverImage: CoverImage?,
text: List<StringWithId>
text: List<String>
): Boolean
suspend fun updateBooks(
@ -39,7 +39,7 @@ interface BookRepository {
suspend fun updateBookWithText(
book: Book,
text: List<StringWithId>
text: List<String>
): Boolean
suspend fun updateCoverImageOfBook(
@ -51,6 +51,7 @@ interface BookRepository {
books: List<Book>
)
suspend fun <T> retrieveDataFromDataStore(
key: Preferences.Key<T>,
defaultValue: T
@ -61,12 +62,14 @@ interface BookRepository {
value: T
)
suspend fun getAllSettings(scope: CoroutineScope): MainState
suspend fun getFilesFromDevice(query: String = ""): Flow<Resource<List<File>>>
suspend fun getBooksFromFiles(files: List<File>): List<NullableBook>
suspend fun insertHistory(history: List<History>)
suspend fun getHistory(): Flow<Resource<List<History>>>
@ -81,5 +84,25 @@ interface BookRepository {
history: List<History>
)
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<String>
suspend fun translateText(
sourceLanguage: LanguageCode,
targetLanguage: LanguageCode,
text: String
): Resource<String>
}

View file

@ -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)
}
}

View file

@ -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
)
}
}

View file

@ -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
)
}
}

View file

@ -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<StringWithId> {
return repository.getBookText(textPath = textPath)
suspend fun execute(textPath: String): Map<ID, LineWithTranslation> {
return repository.getBookText(textPath = textPath).withIndex().associate {
it.index to LineWithTranslation(originalLine = it.value)
}
}
}

View file

@ -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<LanguageCode> {
return repository.identifyLanguage(text)
}
}

View file

@ -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<StringWithId>): Boolean {
suspend fun execute(
book: Book,
coverImage: CoverImage?,
text: List<String>
): Boolean {
return repository.insertBook(book, coverImage, text)
}
}

View file

@ -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)
}
}

View file

@ -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<String> {
return repository.translateText(
sourceLanguage,
targetLanguage,
text
)
}
}

View file

@ -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<StringWithId>): Boolean {
suspend fun execute(book: Book, text: List<String>): Boolean {
return repository.updateBookWithText(book, text)
}
}

View file

@ -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
)
}

View file

@ -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")
}

View file

@ -4,3 +4,6 @@ import android.graphics.Bitmap
typealias CoverImage = Bitmap
typealias Selected = Boolean
typealias ID = Int
typealias LanguageCode = String
typealias Route = String

View file

@ -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 {

View file

@ -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)
}
}
}

View file

@ -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,

View file

@ -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)
}
}

View file

@ -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)
}
}
}
}

View file

@ -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)
)

View file

@ -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,

View file

@ -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)
}
}
}
}

View file

@ -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)
)

View file

@ -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<LanguageHistory>()
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<TranslatorLanguageState>,
loading: State<Boolean>,
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)
)
}
}
}
}
}
}

View file

@ -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<TranslatorLanguageState>,
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
)
}

View file

@ -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
)
)
}
)
}
}
}
}
}

View file

@ -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<TranslatorLanguageState>,
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)
)
)
}
)
}

View file

@ -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()
}

View file

@ -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<Language> = emptyList(),
val languages: List<Language> = emptyList(),
val showSearch: Boolean = false,
val searchQuery: String = "",
val hasFocused: Boolean = false,
)

View file

@ -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<LanguageHistory>,
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()
)
}
)
}
}
}

View file

@ -10,3 +10,61 @@ fun String.removeTrailingZero(): String {
fun Double.removeDigits(digits: Int) = "%.${digits}f".format(this).replace(",", ".")
fun <K, T> Map<K, T>.update(item: Pair<K, T>): Map<K, T> {
val keyExists = containsKey(item.first)
return if (!keyExists) this
else this.plus(item)
}
fun <K, T> Map<K, T>.update(items: List<Pair<K, T>>): Map<K, T> {
var map = this
for (item in items) {
map = map.update(item)
}
return map
}
fun <K, T> Map<K, T>.update(items: Map<K, T>): Map<K, T> {
var map = this
for (item in items) {
map = map.update(item.toPair())
}
return map
}
fun <K, T> Map<K, T>.updateWithCopy(
calculation: (T) -> T
): Map<K, T> {
var map = this
for (item in this) {
map = map.update(item.key to calculation(item.value))
}
return map
}
fun <K, T> Map<K, T>.removeKeys(
keys: Set<K>
): Map<K, T> {
val map = this.toMutableMap()
for (key in keys) {
map.remove(key)
}
return map.toMap()
}
fun <K, T> Map<K, T>.removeKey(
key: K
): Map<K, T> {
val map = this.toMutableMap()
map.remove(key)
return map.toMap()
}

View file

@ -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<LanguageHistory>) : MainEvent()
}

View file

@ -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<List<LanguageHistory>> = 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<LanguageHistory>? = 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<String, Any>): 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<LanguageHistory> =
(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
)
}
}

View file

@ -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
)
}
}
}
}
}

View file

@ -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
)

View file

@ -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(

View file

@ -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)
)
}

View file

@ -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)
)

View file

@ -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)
)
}

View file

@ -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,

View file

@ -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<BookInfoState>,
@ -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
)
)
}

View file

@ -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<BookInfoState>,
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
)
)
}

View file

@ -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<Book, List<StringWithId>>,
val updatedBook: Pair<Book, List<String>>,
val authorUpdated: Boolean,
val descriptionUpdated: Boolean,
val textUpdated: Boolean,

View file

@ -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<Book, List<StringWithId>>? = null,
val updatedBook: Pair<Book, List<String>>? = null,
val authorChanged: Boolean = false,
val descriptionChanged: Boolean = false,
val textChanged: Boolean = false,

View file

@ -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
}

View file

@ -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()

View file

@ -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(

View file

@ -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

View file

@ -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
)

View file

@ -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

View file

@ -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<ReaderState>,
mainState: State<MainState>,
listState: LazyListState,
loading: State<Boolean>,
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()
}

View file

@ -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)
}

View file

@ -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))

View file

@ -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,

View file

@ -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<ReaderState>,
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)
)
}
)
}
}
)
}

View file

@ -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))
}
}
}
}
}
}

View file

@ -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<MainState>,
@ -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
)
)
}
}
}

View file

@ -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<ReaderState>) {
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(

View file

@ -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<ReaderState>,
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
)
)
}
}

View file

@ -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<String, InlineTextContent> {
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()
}
}
)
}
}
}
)
)
}
}

View file

@ -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<ReaderState>,
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
)
)
}
}
}
}

View file

@ -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
)
}
}

View file

@ -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<ID>?,
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<ID, LineWithTranslation>) -> 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

View file

@ -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<StringWithId> = emptyList(),
val text: Map<ID, LineWithTranslation> = emptyMap(),
val words: Int = 0,
val letters: Int = 0,
val listState: LazyListState = LazyListState(),
val languagesToTranslate: List<SelectableLanguage> = 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,
)

View file

@ -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()
}

View file

@ -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,
)

View file

@ -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
)
}
}
}
}
}

View file

@ -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))
)
}

View file

@ -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<MainState>,
settingsState: State<SettingsState>,
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),

View file

@ -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,

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -36,6 +36,8 @@
<string name="move_book">Перемістити книгу?</string>
<string name="delete_history">Видалити історію читання?</string>
<string name="confirm_update">Підтвердити оновлення?</string>
<string name="download_language">Завантажити %1$s?</string>
<string name="download_languages">Завантажити мови?</string>
<!-- Dialog Descriptions -->
<string name="storage_permission_description">
@ -78,6 +80,17 @@
Натиснувши на кнопку «Завантажити», ви перейдете в ваш браузер
та завантажете останнє оновлення автоматично.
</string>
<string name="download_language_description">
Вам потрібно завантажити цю мову перед використанням.
Після завантаження, вона буде доступна офлайн та не потребуватиме інтернету.
Кожна мова має розмір близько ~30мб, для завантаження рекомендується використовувати Wi-Fi.
</string>
<string name="download_languages_description">
Вам потрібно завантажити ці мови перед використанням.
Кожна мова має розмір близько ~30мб, для завантаження рекомендується використовувати Wi-Fi.
Ви можете вибрати мови, які ви хочете завантажити,
всі не вибрані мови не будуть перекладені.
</string>
<!-- Errors -->
<string name="error_permission">
@ -104,6 +117,12 @@
Щось пішло не так.
Спробуйте пізніше.
</string>
<string name="error_language_not_identified">Мова не була ідентифікована.</string>
<string name="error_languages_not_identified">Не вдалося ідентифікувати жодної мови.</string>
<string name="error_cannot_select_language">Неможливо вибрати ту саму мову двічі.</string>
<string name="error_wrong_language">Не можна вибрати цю мову.</string>
<string name="error_could_not_download_language">Не вдалося завантажити мову.</string>
<string name="error_could_not_download_languages">Не вдалося завантажити мови.</string>
<!-- Is Empty messages -->
<string name="browse_empty">
@ -116,6 +135,9 @@
Нехай ваша остання прочитана книга відкриє перед
вами сторінки нової великої історії вашого читацького шляху
</string>
<string name="translator_languages_empty">
Не існує мови з такою назвою, або вона не підтримується
</string>
<!-- Query strings -->
<string name="selected_items_count_query">%1$s обрано</string>
@ -150,11 +172,14 @@
<string name="no">Ні</string>
<string name="download">Завантажити</string>
<string name="done">Готово</string>
<string name="undo_translation">Відмінити</string>
<string name="continue_download">Далі</string>
<!-- Items -->
<string name="files">файли</string>
<string name="books">книги</string>
<string name="history">історію</string>
<string name="languages">мови</string>
<!-- Book -->
<string name="unknown_author">Невідомий</string>
@ -181,13 +206,14 @@
<string name="general_settings">Головні</string>
<string name="general_settings_desc">Мова застосунка</string>
<string name="appearance_settings">Зовнішній вигляд</string>
<string name="appearance_settings_desc">Налаштування теми, кольори</string>
<string name="appearance_settings_desc">Тема, кольори</string>
<string name="reader_settings">Читач</string>
<string name="reader_settings_desc">Налаштування шрифта, налаштування текста</string>
<string name="reader_settings_desc">Перекладач, шрифт, текст</string>
<!-- Settings subcategories -->
<string name="font_reader_settings">Шрифт</string>
<string name="text_reader_settings">Текст</string>
<string name="translator_reader_settings">Перекладач</string>
<string name="theme_appearance_settings">Налаштування теми</string>
<string name="colors_appearance_settings">Кольори</string>
@ -208,6 +234,13 @@
<string name="check_for_updates_option">Перевіряти на оновлення</string>
<string name="check_for_updates_option_desc">Перевіряти на новішу версію застосунку при запуску</string>
<string name="side_padding_option">Бокові відступи</string>
<string name="translator_language_option">Мова перекладача</string>
<string name="translator_enable_default_option">Увімкнути перекладач</string>
<string name="translator_enable_default_option_desc">Увімкнути перекладач при додаванні книги за замовчуванням</string>
<string name="translator_double_click_to_translate_option">Переклад подвійним кліком</string>
<string name="translator_double_click_to_translate_option_desc">Двічі клацніть на абзац, щоб перекласти його</string>
<string name="translator_translate_on_start_option">Перекладати при відкритті книги</string>
<string name="translator_translate_on_start_option_desc">Автоматично перекладати весь текст при відкритті книги</string>
<!-- Colors -->
<string name="red_color">Червоний</string>
@ -255,7 +288,12 @@
<string name="book_moved">Ця книга була успішно переміщена.</string>
<string name="copied">Скопійовано</string>
<string name="history_deleted">Історія була успішно видалена.</string>
<string name="no_updates">Остання версія застосунку вже встановлена.</string>
<string name="no_updates">Остання версія застосунка вже встановлена.</string>
<string name="language_downloaded">Мова була успішно завантажена та вибрана.</string>
<string name="languages_downloaded">Всі необхідні мови завантажені. Переклад почнеться незабаром.</string>
<string name="translator_canceling">Переклад відміняється. Будь ласка, зачекайте.</string>
<string name="translator_text_canceling">Перекладений текст відміняється. Будь ласка, зачекайте.</string>
<string name="translator_starting">Переклад скоро почнеться. Будь ласка, зачекайте.</string>
<!-- Snackbar messages -->
<string name="history_element_deleted">Елемент історії був успішно видалений.</string>
@ -282,14 +320,25 @@
Дякуємо за те, що прочитали \"%1$s\" з нами.
Ми сподіваємось, що вам сподобалася ця книга!
</string>
<string name="letters_and_words">%1$s букв, %2$s слів.</string>
<string name="letters_and_words">%1$s букв(а), %2$s слів.</string>
<!-- Translator -->
<string name="translator_auto">Визначити мову</string>
<string name="translator_auto_short">Визначити</string>
<string name="translator_query">Перекласти %1$s</string>
<string name="translator_from">з</string>
<string name="translator_to">на</string>
<string name="translator_all_languages">Всі мови</string>
<string name="translator_recent_languages">Недавні мови</string>
<string name="translator_occurrences">%1$s абзац(ів)</string>
<string name="translator_show_original">Показати оригінал (%1$s на %2$s)</string>
<!-- About -->
<string name="app_version_option">Версія застосунку</string>
<string name="app_version_option">Версія застосунка</string>
<string name="app_version_option_desc_1">Версія v%1$s</string>
<string name="app_version_option_desc_2">Натисніть, щоб перевірити на оновлення</string>
<string name="report_bug_option">Повідомити про помилку</string>
<string name="project_page_option">Сторінка проєкту</string>
<string name="project_page_option">Сторінка проєкта</string>
<string name="support_option">Підтримати мою роботу</string>
<string name="licenses_option">Ліцензії з відкритим кодом</string>
<string name="credits_option">Заслуги</string>
@ -474,6 +523,8 @@
<string name="reset_start_content_desc">Скинути початковий посібник</string>
<string name="hide_edit_title_content_desc">Вийти з редагування назви</string>
<string name="open_in_web_content_desc">Відкрити в браузері</string>
<string name="translator_content_desc">Перекладач</string>
<string name="translator_language_content_desc">Поміняти мови місцями</string>
<string name="downloaded_content_desc">Завантажено</string>
</resources>

View file

@ -55,6 +55,8 @@
<string name="move_book">Move book?</string>
<string name="delete_history">Delete reading history?</string>
<string name="confirm_update">Confirm update?</string>
<string name="download_language">Download %1$s?</string>
<string name="download_languages">Download languages?</string>
<!-- Dialog Descriptions -->
<string name="storage_permission_description">
@ -97,6 +99,17 @@
By clicking the «Download» button you will be transferred
to your browser and download the latest update automatically.
</string>
<string name="download_language_description">
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.
</string>
<string name="download_languages_description">
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.
</string>
<!-- Errors -->
<string name="error_permission">
@ -123,6 +136,12 @@
Something went wrong.
Try again later.
</string>
<string name="error_language_not_identified">Could not identify language.</string>
<string name="error_languages_not_identified">Could not identify any languages.</string>
<string name="error_cannot_select_language">Cannot select same language twice.</string>
<string name="error_wrong_language">Cannot select this language.</string>
<string name="error_could_not_download_language">Could not download language.</string>
<string name="error_could_not_download_languages">Could not download languages.</string>
<!-- Is Empty messages -->
<string name="browse_empty">
@ -134,6 +153,9 @@
<string name="history_empty">
Let your last book you read open the pages of a new great story of your reading journey
</string>
<string name="translator_languages_empty">
There is no such language with this name, or it is not supported
</string>
<!-- Query strings -->
<string name="selected_items_count_query">%1$s selected</string>
@ -168,11 +190,14 @@
<string name="no">No</string>
<string name="download">Download</string>
<string name="done">Done</string>
<string name="undo_translation">Undo</string>
<string name="continue_download">Continue</string>
<!-- Items -->
<string name="files">files</string>
<string name="books">books</string>
<string name="history">history</string>
<string name="languages">languages</string>
<!-- Book -->
<string name="unknown_author">Unknown</string>
@ -199,13 +224,14 @@
<string name="general_settings">General</string>
<string name="general_settings_desc">App language</string>
<string name="appearance_settings">Appearance</string>
<string name="appearance_settings_desc">Theme preferences, colors</string>
<string name="appearance_settings_desc">Theme, colors</string>
<string name="reader_settings">Reader</string>
<string name="reader_settings_desc">Font settings, text settings</string>
<string name="reader_settings_desc">Translator, font, text</string>
<!-- Settings subcategories -->
<string name="font_reader_settings">Font</string>
<string name="text_reader_settings">Text</string>
<string name="translator_reader_settings">Translator</string>
<string name="theme_appearance_settings">Theme preferences</string>
<string name="colors_appearance_settings">Colors</string>
@ -226,6 +252,13 @@
<string name="check_for_updates_option">Check for updates</string>
<string name="check_for_updates_option_desc">Check for newer app version on startup</string>
<string name="side_padding_option">Side padding</string>
<string name="translator_language_option">Translator language</string>
<string name="translator_enable_default_option">Enable translator</string>
<string name="translator_enable_default_option_desc">Enable translator when adding a book by default</string>
<string name="translator_double_click_to_translate_option">Double click translation</string>
<string name="translator_double_click_to_translate_option_desc">Double click on paragraph to translate it</string>
<string name="translator_translate_on_start_option">Translate when open a book</string>
<string name="translator_translate_on_start_option_desc">Automatically translate the whole text when open a book</string>
<!-- Colors -->
<string name="red_color">Red</string>
@ -274,6 +307,13 @@
<string name="copied">Copied</string>
<string name="history_deleted">History was successfully deleted.</string>
<string name="no_updates">The latest version of the app is already installed.</string>
<string name="language_downloaded">Language was successfully downloaded and selected.</string>
<string name="languages_downloaded">
All required languages are downloaded. Translation will start soon.
</string>
<string name="translator_canceling">Translation is canceling. Please wait.</string>
<string name="translator_text_canceling">Translated text is canceling. Please wait.</string>
<string name="translator_starting">Translation will start soon. Please wait.</string>
<!-- Snackbar messages -->
<string name="history_element_deleted">History element was successfully deleted.</string>
@ -302,6 +342,17 @@
</string>
<string name="letters_and_words">%1$s characters, %2$s words.</string>
<!-- Translator -->
<string name="translator_auto">Detect language</string>
<string name="translator_auto_short">Detect</string>
<string name="translator_query">Translate %1$s</string>
<string name="translator_from">from</string>
<string name="translator_to">to</string>
<string name="translator_all_languages">All languages</string>
<string name="translator_recent_languages">Recent languages</string>
<string name="translator_occurrences">%1$s paragraph(s)</string>
<string name="translator_show_original">Show original (%1$s to %2$s)</string>
<!-- About -->
<string name="app_version_option">App version</string>
<string name="app_version_option_desc_1">Version v%1$s</string>
@ -493,5 +544,8 @@
<string name="reset_start_content_desc">Reset start guide</string>
<string name="hide_edit_title_content_desc">Exit title editing</string>
<string name="open_in_web_content_desc">Open in web</string>
<string name="translator_content_desc">Translator</string>
<string name="translator_language_content_desc">Change language places</string>
<string name="downloaded_content_desc">Downloaded</string>
</resources>

View file

@ -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
}

Some files were not shown because too many files have changed in this diff Show more