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"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.21" /> <option name="version" value="2.0.0" />
</component> </component>
</project> </project>

View file

@ -1,10 +1,13 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp") id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android") id("com.google.dagger.hilt.android")
id("kotlin-parcelize") id("kotlin-parcelize")
id("com.mikepenz.aboutlibraries.plugin") id("com.mikepenz.aboutlibraries.plugin")
id("androidx.room")
id("org.jetbrains.kotlin.plugin.serialization")
} }
android { android {
@ -24,6 +27,10 @@ android {
} }
} }
room {
schemaDirectory("$projectDir/schemas")
}
buildTypes { buildTypes {
getByName("debug") { getByName("debug") {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
@ -76,12 +83,12 @@ aboutLibraries {
dependencies { dependencies {
// Default // Default
implementation("androidx.core:core-ktx:1.13.0") implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.1")
implementation("androidx.activity:activity-compose:1.9.0") implementation("androidx.activity:activity-compose:1.9.0")
// Compose BOM libraries // 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.foundation:foundation")
implementation("androidx.compose.animation:animation") implementation("androidx.compose.animation:animation")
implementation("androidx.compose.animation:animation-android") implementation("androidx.compose.animation:animation-android")
@ -90,18 +97,18 @@ dependencies {
implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.ui:ui-android") 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.material3:material3-window-size-class")
implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.material:material") implementation("androidx.compose.material:material")
// All dependencies // 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") implementation("com.google.accompanist:accompanist-swiperefresh:0.24.2-alpha")
// Dagger - Hilt // Dagger - Hilt
implementation("com.google.dagger:hilt-android:2.51.1") 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") ksp("com.google.dagger:hilt-android-compiler:2.51")
implementation("com.google.dagger:hilt-compiler:latest.release") implementation("com.google.dagger:hilt-compiler:latest.release")
ksp("androidx.hilt:hilt-compiler:latest.release") ksp("androidx.hilt:hilt-compiler:latest.release")
@ -152,4 +159,11 @@ dependencies {
// About open source libraries // About open source libraries
implementation("com.mikepenz:aboutlibraries-core:latest.release") implementation("com.mikepenz:aboutlibraries-core:latest.release")
implementation("com.mikepenz:aboutlibraries-compose-m3: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.NavigationHost
import ua.acclorite.book_story.presentation.data.Screen 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.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.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.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.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.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.BrowseScreenRoot
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel 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.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.HistoryScreenRoot
import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel 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.LibraryScreenRoot
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel 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.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.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.appearance.AppearanceSettingsRoot
import ua.acclorite.book_story.presentation.screens.settings.nested.general.GeneralSettingsRoot 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.settings.nested.reader.ReaderSettingsRoot
import ua.acclorite.book_story.presentation.screens.start.StartScreenRoot 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.BookStoryTheme
import ua.acclorite.book_story.presentation.ui.Transitions import ua.acclorite.book_story.presentation.ui.Transitions
import ua.acclorite.book_story.presentation.ui.isDark import ua.acclorite.book_story.presentation.ui.isDark
@ -66,14 +58,6 @@ class Activity : AppCompatActivity() {
private val libraryViewModel: LibraryViewModel by viewModels() private val libraryViewModel: LibraryViewModel by viewModels()
private val historyViewModel: HistoryViewModel by viewModels() private val historyViewModel: HistoryViewModel by viewModels()
private val browseViewModel: BrowseViewModel 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -102,8 +86,8 @@ class Activity : AppCompatActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
val isLoaded by mainViewModel.isReady.collectAsState() val isLoaded = mainViewModel.isReady.collectAsState()
val state by mainViewModel.state.collectAsState() val state = mainViewModel.state.collectAsState()
val density = LocalDensity.current val density = LocalDensity.current
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
@ -120,15 +104,15 @@ class Activity : AppCompatActivity() {
} }
} }
if (isLoaded) { if (isLoaded.value) {
BookStoryTheme( BookStoryTheme(
theme = state.theme!!, theme = state.value.theme!!,
isDark = state.darkTheme!!.isDark(), isDark = state.value.darkTheme!!.isDark(),
isPureDark = state.pureDark!!.isPureDark(this), isPureDark = state.value.pureDark!!.isPureDark(this),
themeContrast = state.themeContrast!! themeContrast = state.value.themeContrast!!
) { ) {
NavigationHost( NavigationHost(
startScreen = if (state.showStartScreen!!) Screen.START startScreen = if (state.value.showStartScreen!!) Screen.START
else Screen.LIBRARY else Screen.LIBRARY
) { ) {
navigation( navigation(

View file

@ -2,6 +2,10 @@ package ua.acclorite.book_story.data.di
import android.app.Application import android.app.Application
import androidx.room.Room 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.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module import dagger.Module
@ -41,4 +45,20 @@ object AppModule {
"book_db" "book_db"
).allowMainThreadQueries().build().dao ).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 package ua.acclorite.book_story.data.local.dto
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
@ -16,5 +17,10 @@ data class BookEntity(
val scrollOffset: Int, val scrollOffset: Int,
val progress: Float, val progress: Float,
val image: String? = null, 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 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 { interface UpdatesNotificationService {
fun postNotification(releaseInfo: ReleaseResponse) fun postNotification(releaseInfo: LatestReleaseInfo)
companion object { companion object {
const val CHANNEL_ID = "updates_channel" const val CHANNEL_ID = "updates_channel"

View file

@ -8,7 +8,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import ua.acclorite.book_story.R 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 import javax.inject.Inject
class UpdatesNotificationServiceImpl @Inject constructor( class UpdatesNotificationServiceImpl @Inject constructor(
@ -18,7 +18,7 @@ class UpdatesNotificationServiceImpl @Inject constructor(
private val notificationManager = application.getSystemService(Context.NOTIFICATION_SERVICE) private val notificationManager = application.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager as NotificationManager
override fun postNotification(releaseInfo: ReleaseResponse) { override fun postNotification(releaseInfo: LatestReleaseInfo) {
val downloadLatestVersionIntent = Intent( val downloadLatestVersionIntent = Intent(
Intent.ACTION_VIEW, Intent.ACTION_VIEW,
Uri.parse(application.getString(R.string.download_latest_release_page)) Uri.parse(application.getString(R.string.download_latest_release_page))

View file

@ -1,5 +1,6 @@
package ua.acclorite.book_story.data.local.room package ua.acclorite.book_story.data.local.room
import androidx.room.AutoMigration
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import ua.acclorite.book_story.data.local.dto.BookEntity import ua.acclorite.book_story.data.local.dto.BookEntity
@ -10,8 +11,11 @@ import ua.acclorite.book_story.data.local.dto.HistoryEntity
BookEntity::class, BookEntity::class,
HistoryEntity::class HistoryEntity::class
], ],
version = 1, version = 2,
exportSchema = false autoMigrations = [
AutoMigration(1, 2)
],
exportSchema = true
) )
abstract class BookDatabase : RoomDatabase() { abstract class BookDatabase : RoomDatabase() {
abstract val dao: BookDao abstract val dao: BookDao

View file

@ -20,7 +20,12 @@ class BookMapperImpl @Inject constructor() : BookMapper {
textPath = book.textPath, textPath = book.textPath,
description = book.description, description = book.description,
image = if (book.coverImage != null) book.coverImage.toString() else null, image = if (book.coverImage != null) book.coverImage.toString() else null,
category = book.category category = book.category,
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, filePath = bookEntity.filePath,
lastOpened = null, lastOpened = null,
category = bookEntity.category, category = bookEntity.category,
coverImage = if (bookEntity.image != null) Uri.parse(bookEntity.image) else null coverImage = if (bookEntity.image != null) Uri.parse(bookEntity.image) else null,
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 package ua.acclorite.book_story.data.parser
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.Resource
import java.io.File import java.io.File
interface TextParser { interface TextParser {
suspend fun parse(file: File): Resource<List<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.epub.EpubTextParser
import ua.acclorite.book_story.data.parser.pdf.PdfTextParser import ua.acclorite.book_story.data.parser.pdf.PdfTextParser
import ua.acclorite.book_story.data.parser.txt.TxtTextParser import ua.acclorite.book_story.data.parser.txt.TxtTextParser
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
import java.io.File import java.io.File
@ -15,7 +14,7 @@ class TextParserImpl @Inject constructor(
private val pdfTextParser: PdfTextParser, private val pdfTextParser: PdfTextParser,
private val epubTextParser: EpubTextParser, private val epubTextParser: EpubTextParser,
) : TextParser { ) : TextParser {
override suspend fun parse(file: File): Resource<List<StringWithId>> { override suspend fun parse(file: File): Resource<List<String>> {
val fileFormat = ".${file.name.substringAfterLast(".")}" val fileFormat = ".${file.name.substringAfterLast(".")}"
if (fileFormat == ".pdf") { if (fileFormat == ".pdf") {

View file

@ -55,7 +55,12 @@ class EpubFileParser @Inject constructor() : FileParser {
filePath = file.path, filePath = file.path,
lastOpened = null, lastOpened = null,
category = Category.entries[0], category = Category.entries[0],
coverImage = null coverImage = null,
enableTranslator = false,
translateFrom = "",
translateTo = "",
doubleClickTranslation = false,
translateWhenOpen = false
) to coverImage ) to coverImage
} catch (e: Exception) { } catch (e: Exception) {
return null return null

View file

@ -8,7 +8,6 @@ import org.jsoup.nodes.Document.OutputSettings
import org.jsoup.safety.Safelist import org.jsoup.safety.Safelist
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
import java.io.BufferedReader import java.io.BufferedReader
@ -21,7 +20,7 @@ import javax.inject.Inject
class EpubTextParser @Inject constructor() : TextParser { 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")) { if (!file.name.endsWith(".epub")) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format)) 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()) val parsedText = Jsoup.parse(unformattedText.toString())
parsedText.outputSettings(OutputSettings().prettyPrint(false)) parsedText.outputSettings(OutputSettings().prettyPrint(false))
@ -78,15 +77,15 @@ class EpubTextParser @Inject constructor() : TextParser {
.split("\n") .split("\n")
.forEach { .forEach {
if (it.isNotBlank()) { 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.Error(UIText.StringResource(R.string.error_file_empty))
} }
return Resource.Success(stringWithIds) return Resource.Success(strings)
} catch (e: Exception) { } catch (e: Exception) {
return Resource.Error( return Resource.Error(
UIText.StringResource( UIText.StringResource(

View file

@ -43,7 +43,12 @@ class PdfFileParser @Inject constructor(private val application: Application) :
filePath = file.path, filePath = file.path,
lastOpened = null, lastOpened = null,
category = Category.entries[0], category = Category.entries[0],
coverImage = null coverImage = null,
enableTranslator = false,
translateFrom = "",
translateTo = "",
doubleClickTranslation = false,
translateWhenOpen = false
) to null ) to null
} catch (e: Exception) { } catch (e: Exception) {
return null return null

View file

@ -6,7 +6,6 @@ import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.text.PDFTextStripper import com.tom_roush.pdfbox.text.PDFTextStripper
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
import java.io.File import java.io.File
@ -14,7 +13,7 @@ import javax.inject.Inject
class PdfTextParser @Inject constructor(private val application: Application) : TextParser { 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")) { if (!file.name.endsWith(".pdf")) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format)) 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) PDFBoxResourceLoader.init(application)
val document = PDDocument.load(file) val document = PDDocument.load(file)
val stringWithIds = mutableListOf<StringWithId>() val strings = mutableListOf<String>()
val pdfStripper = PDFTextStripper() val pdfStripper = PDFTextStripper()
pdfStripper.paragraphStart = "</br>" pdfStripper.paragraphStart = "</br>"
@ -103,14 +102,14 @@ class PdfTextParser @Inject constructor(private val application: Application) :
} }
lines.forEach { line -> 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.Error(UIText.StringResource(R.string.error_file_empty))
} }
return Resource.Success(stringWithIds) return Resource.Success(strings)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
return Resource.Error( return Resource.Error(

View file

@ -31,7 +31,12 @@ class TxtFileParser @Inject constructor() : FileParser {
filePath = file.path, filePath = file.path,
lastOpened = null, lastOpened = null,
category = Category.entries[0], category = Category.entries[0],
coverImage = null coverImage = null,
enableTranslator = false,
translateFrom = "",
translateTo = "",
doubleClickTranslation = false,
translateWhenOpen = false
) to null ) to null
} catch (e: Exception) { } catch (e: Exception) {
return null return null

View file

@ -4,7 +4,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
import java.io.BufferedReader import java.io.BufferedReader
@ -14,19 +13,19 @@ import javax.inject.Inject
class TxtTextParser @Inject constructor() : TextParser { 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")) { if (!file.name.endsWith(".txt")) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format)) return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
} }
return try { return try {
val formattedLines = mutableListOf<StringWithId>() val formattedLines = mutableListOf<String>()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
BufferedReader(FileReader(file)).forEachLine { line -> BufferedReader(FileReader(file)).forEachLine { line ->
if (line.isNotBlank()) { if (line.isNotBlank()) {
formattedLines.add( formattedLines.add(
StringWithId(line) line.trim()
) )
} }
} }

View file

@ -1,11 +1,11 @@
package ua.acclorite.book_story.data.remote package ua.acclorite.book_story.data.remote
import retrofit2.http.GET import retrofit2.http.GET
import ua.acclorite.book_story.data.remote.dto.ReleaseResponse import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo
interface GithubAPI { interface GithubAPI {
@GET("repos/Acclorite/book-story/releases/latest") @GET("repos/Acclorite/book-story/releases/latest")
suspend fun getLatestRelease(): ReleaseResponse suspend fun getLatestRelease(): LatestReleaseInfo
companion object { companion object {
const val BASE_URL = "https://api.github.com/" const val BASE_URL = "https://api.github.com/"

View file

@ -8,7 +8,7 @@ import com.squareup.moshi.JsonClass
@Keep @Keep
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@Immutable @Immutable
data class ReleaseResponse( data class LatestReleaseInfo(
@Json(name = "name") val name: String, @Json(name = "name") val name: String,
@Json(name = "tag_name") val tagName: String @Json(name = "tag_name") val tagName: String
) )

View file

@ -6,6 +6,13 @@ import android.net.Uri
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
import androidx.datastore.preferences.core.Preferences 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.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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.FileParser
import ua.acclorite.book_story.data.parser.TextParser import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.data.remote.GithubAPI import ua.acclorite.book_story.data.remote.GithubAPI
import ua.acclorite.book_story.data.remote.dto.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.Book
import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.repository.BookRepository import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.domain.util.CoverImage 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.Resource
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
import ua.acclorite.book_story.presentation.data.MainState import ua.acclorite.book_story.presentation.data.MainState
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.Locale
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Suppress("DEPRECATION")
@Singleton @Singleton
class BookRepositoryImpl @Inject constructor( class BookRepositoryImpl @Inject constructor(
private val application: Application, private val application: Application,
private val database: BookDao, private val database: BookDao,
private val dataStore: DataStore, private val dataStore: DataStore,
private val modelManager: RemoteModelManager,
private val languageIdentifier: LanguageIdentifier,
private val githubAPI: GithubAPI, private val githubAPI: GithubAPI,
private val updatesNotificationService: UpdatesNotificationService, 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) val textFile = File(textPath)
if (textPath.isBlank() || !textFile.exists()) { if (textPath.isBlank() || !textFile.exists()) {
@ -108,7 +122,7 @@ class BookRepositoryImpl @Inject constructor(
override suspend fun insertBook( override suspend fun insertBook(
book: Book, book: Book,
coverImage: CoverImage?, coverImage: CoverImage?,
text: List<StringWithId> text: List<String>
): Boolean { ): Boolean {
val filesDir = application.filesDir val filesDir = application.filesDir
val coversDir = File(filesDir, "covers") val coversDir = File(filesDir, "covers")
@ -135,7 +149,7 @@ class BookRepositoryImpl @Inject constructor(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
FileOutputStream(textPath).use { stream -> FileOutputStream(textPath).use { stream ->
text.forEach { line -> text.forEach { line ->
stream.write(line.line.toByteArray()) stream.write(line.toByteArray())
stream.write(System.lineSeparator().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( val updatedBook = book.copy(
textPath = "$booksDir/$textUri", textPath = "$booksDir/$textUri",
coverImage = if (coverUri.isNotBlank()) { coverImage = if (coverUri.isNotBlank()) {
Uri.fromFile(File("$coversDir/$coverUri")) Uri.fromFile(File("$coversDir/$coverUri"))
} else null } else null,
enableTranslator = enableTranslator,
translateFrom = translateFrom,
translateTo = translateTo,
doubleClickTranslation = doubleClickTranslation
) )
val bookToInsert = bookMapper.toBookEntity(updatedBook) 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 // without cover image
val filesDir = application.filesDir val filesDir = application.filesDir
val booksDir = File(filesDir, "books") val booksDir = File(filesDir, "books")
@ -220,7 +256,7 @@ class BookRepositoryImpl @Inject constructor(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
FileOutputStream(textPath).use { stream -> FileOutputStream(textPath).use { stream ->
text.forEach { line -> text.forEach { line ->
stream.write(line.line.toByteArray()) stream.write(line.toByteArray())
stream.write(System.lineSeparator().toByteArray()) stream.write(System.lineSeparator().toByteArray())
} }
} }
@ -604,7 +640,7 @@ class BookRepositoryImpl @Inject constructor(
database.deleteHistory(history.map { historyMapper.toHistoryEntity(it) }) 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) { return withContext(Dispatchers.IO) {
try { try {
val result = githubAPI.getLatestRelease() 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 lastOpened: Long?,
val category: Category, 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 android.os.Parcelable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.util.UUID
@Parcelize @Parcelize
@Immutable @Immutable
data class StringWithId( data class LanguageHistory(
val line: String, val id: Int,
val id: String = UUID.randomUUID().toString() val languageCode: String
) : Parcelable ) : 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 package ua.acclorite.book_story.domain.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.painter.Painter import ua.acclorite.book_story.presentation.data.Screen
@Immutable @Immutable
data class NavigationItem( data class NavigationItem(
val title: String, val screen: Screen,
val selectedIcon: Painter, @StringRes val title: Int,
val unselectedIcon: Painter @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( sealed class NullableBook(
val book: Book?, val book: Book?,
val coverImage: CoverImage? = null, val coverImage: CoverImage? = null,
val text: List<StringWithId> = emptyList(), val text: List<String> = emptyList(),
val fileName: String?, val fileName: String?,
val message: UIText? val message: UIText?
) { ) {
class NotNull( class NotNull(
book: Book, book: Book,
coverImage: CoverImage?, coverImage: CoverImage?,
text: List<StringWithId> text: List<String>
) : NullableBook( ) : NullableBook(
book = book, book = book,
text = text, text = text,

View file

@ -3,12 +3,12 @@ package ua.acclorite.book_story.domain.repository
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow 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.Book
import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.CoverImage 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.domain.util.Resource
import ua.acclorite.book_story.presentation.data.MainState import ua.acclorite.book_story.presentation.data.MainState
import java.io.File import java.io.File
@ -25,12 +25,12 @@ interface BookRepository {
suspend fun getBookText( suspend fun getBookText(
textPath: String textPath: String
): List<StringWithId> ): List<String>
suspend fun insertBook( suspend fun insertBook(
book: Book, book: Book,
coverImage: CoverImage?, coverImage: CoverImage?,
text: List<StringWithId> text: List<String>
): Boolean ): Boolean
suspend fun updateBooks( suspend fun updateBooks(
@ -39,7 +39,7 @@ interface BookRepository {
suspend fun updateBookWithText( suspend fun updateBookWithText(
book: Book, book: Book,
text: List<StringWithId> text: List<String>
): Boolean ): Boolean
suspend fun updateCoverImageOfBook( suspend fun updateCoverImageOfBook(
@ -51,6 +51,7 @@ interface BookRepository {
books: List<Book> books: List<Book>
) )
suspend fun <T> retrieveDataFromDataStore( suspend fun <T> retrieveDataFromDataStore(
key: Preferences.Key<T>, key: Preferences.Key<T>,
defaultValue: T defaultValue: T
@ -61,12 +62,14 @@ interface BookRepository {
value: T value: T
) )
suspend fun getAllSettings(scope: CoroutineScope): MainState suspend fun getAllSettings(scope: CoroutineScope): MainState
suspend fun getFilesFromDevice(query: String = ""): Flow<Resource<List<File>>> suspend fun getFilesFromDevice(query: String = ""): Flow<Resource<List<File>>>
suspend fun getBooksFromFiles(files: List<File>): List<NullableBook> suspend fun getBooksFromFiles(files: List<File>): List<NullableBook>
suspend fun insertHistory(history: List<History>) suspend fun insertHistory(history: List<History>)
suspend fun getHistory(): Flow<Resource<List<History>>> suspend fun getHistory(): Flow<Resource<List<History>>>
@ -81,5 +84,25 @@ interface BookRepository {
history: List<History> 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 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 ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Inject import javax.inject.Inject
class CheckForUpdates @Inject constructor(private val repository: BookRepository) { class CheckForUpdates @Inject constructor(private val repository: BookRepository) {
suspend fun execute(postNotification: Boolean): ReleaseResponse? { suspend fun execute(postNotification: Boolean): LatestReleaseInfo? {
return repository.checkForUpdates(postNotification) 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 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.repository.BookRepository
import ua.acclorite.book_story.domain.util.ID
import javax.inject.Inject import javax.inject.Inject
class GetText @Inject constructor(private val repository: BookRepository) { class GetText @Inject constructor(private val repository: BookRepository) {
suspend fun execute(textPath: String): List<StringWithId> { suspend fun execute(textPath: String): Map<ID, LineWithTranslation> {
return repository.getBookText(textPath = textPath) 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 package ua.acclorite.book_story.domain.use_case
import ua.acclorite.book_story.domain.model.Book 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.repository.BookRepository
import ua.acclorite.book_story.domain.util.CoverImage import ua.acclorite.book_story.domain.util.CoverImage
import javax.inject.Inject import javax.inject.Inject
class InsertBook @Inject constructor(private val repository: BookRepository) { 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) 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 package ua.acclorite.book_story.domain.use_case
import ua.acclorite.book_story.domain.model.Book 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.repository.BookRepository
import javax.inject.Inject import javax.inject.Inject
class UpdateBookWithText @Inject constructor(private val repository: BookRepository) { 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) 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.Category
import ua.acclorite.book_story.domain.model.Credit import ua.acclorite.book_story.domain.model.Credit
import ua.acclorite.book_story.domain.model.FontWithName import ua.acclorite.book_story.domain.model.FontWithName
import ua.acclorite.book_story.domain.model.NavigationItem
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.ui.Theme import ua.acclorite.book_story.presentation.ui.Theme
object Constants { object Constants {
@ -25,6 +27,31 @@ object Constants {
Pair("uk", "Українська"), 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 // Supported themes
val THEMES = listOf( val THEMES = listOf(
Pair(Theme.DYNAMIC, UIText.StringResource(R.string.dynamic_theme)), Pair(Theme.DYNAMIC, UIText.StringResource(R.string.dynamic_theme)),
@ -255,6 +282,11 @@ object Constants {
scrollOffset = 0, scrollOffset = 0,
progress = 0f, progress = 0f,
lastOpened = null, 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 import androidx.datastore.preferences.core.stringPreferencesKey
object DataStoreConstants { object DataStoreConstants {
// App settings
val LANGUAGE = stringPreferencesKey("language") val LANGUAGE = stringPreferencesKey("language")
val THEME = stringPreferencesKey("theme") val THEME = stringPreferencesKey("theme")
val DARK_THEME = stringPreferencesKey("dark_theme") val DARK_THEME = stringPreferencesKey("dark_theme")
@ -22,4 +23,9 @@ object DataStoreConstants {
val PARAGRAPH_INDENTATION = booleanPreferencesKey("paragraph_indentation") val PARAGRAPH_INDENTATION = booleanPreferencesKey("paragraph_indentation")
val CHECK_FOR_UPDATES = booleanPreferencesKey("check_for_updates") val CHECK_FOR_UPDATES = booleanPreferencesKey("check_for_updates")
val SIDE_PADDING = intPreferencesKey("side_padding") 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 CoverImage = Bitmap
typealias Selected = Boolean 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 package ua.acclorite.book_story.domain.util
import android.content.Context import android.content.Context
import android.os.Parcelable
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import kotlinx.parcelize.Parcelize
import java.io.Serializable
@Parcelize
sealed class UIText(val string: String?) { @Immutable
sealed class UIText(val string: String?) : Parcelable {
data class StringValue(val value: String) : UIText(value) 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 @Composable
fun asString(): String { 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.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@ -20,14 +19,21 @@ import ua.acclorite.book_story.R
/** /**
* Custom Checkbox. Has a Circle shape. * 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 @Composable
fun CustomCheckbox(selected: Boolean, containerColor: Color, size: Dp = 22.dp) { fun CustomCheckbox(
selected: Boolean,
containerColor: Color,
size: Dp = 22.dp
) {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
tint = if (selected) containerColor else Color.Transparent, tint = if (selected) containerColor else Color.Transparent,
modifier = Modifier modifier = Modifier
.clip(CircleShape)
.border( .border(
width = if (!selected) 1.dp else 0.dp, width = if (!selected) 1.dp else 0.dp,
if (!selected) MaterialTheme.colorScheme.outline else Color.Transparent, if (!selected) MaterialTheme.colorScheme.outline else Color.Transparent,

View file

@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")
package ua.acclorite.book_story.presentation.components package ua.acclorite.book_story.presentation.components
import android.content.ClipData import android.content.ClipData
@ -7,6 +9,7 @@ import android.view.ActionMode
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@ -15,6 +18,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalTextToolbar import androidx.compose.ui.platform.LocalTextToolbar
@ -42,13 +46,13 @@ private class CustomTextActionModeCallback(
requireNotNull(menu) requireNotNull(menu)
requireNotNull(mode) requireNotNull(mode)
onTranslateRequested?.let { onCopyRequested?.let {
menu.add(0, MENU_ITEM_TRANSLATE, 0, context.getString(R.string.translate)) menu.add(0, MENU_ITEM_COPY, 0, context.getString(R.string.copy))
.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) .setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER)
} }
onCopyRequested?.let { onTranslateRequested?.let {
menu.add(0, MENU_ITEM_COPY, 1, context.getString(R.string.copy)) menu.add(0, MENU_ITEM_TRANSLATE, 1, context.getString(R.string.translate))
.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) .setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER)
} }
@ -233,7 +237,7 @@ fun CustomSelectionContainer(
CompositionLocalProvider( CompositionLocalProvider(
LocalTextToolbar provides customSelectionToolbar LocalTextToolbar provides customSelectionToolbar
) { ) {
SelectionContainer { SelectionContainer(Modifier.fillMaxSize()) {
content(isToolbarHidden.value) content(isToolbarHidden.value)
} }
} }

View file

@ -7,10 +7,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.res.painterResource import ua.acclorite.book_story.domain.util.Constants
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.presentation.data.LocalNavigator import ua.acclorite.book_story.presentation.data.LocalNavigator
import ua.acclorite.book_story.presentation.data.Screen import ua.acclorite.book_story.presentation.data.Screen
@ -31,41 +28,13 @@ fun BottomNavigationBar() {
} }
NavigationBar { NavigationBar {
Constants.NAVIGATION_ITEMS.forEach {
BottomNavigationBarItem( BottomNavigationBarItem(
item = NavigationItem( item = it,
stringResource(id = R.string.library_screen), isSelected = currentScreen == it.screen
selectedIcon = painterResource(id = R.drawable.library_screen_filled), ) {
unselectedIcon = painterResource(id = R.drawable.library_screen_outlined) navigator.navigate(it.screen, false)
), }
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)
} }
} }
} }

View file

@ -8,6 +8,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.model.NavigationItem 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]. * 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 @Composable
fun RowScope.BottomNavigationBarItem( fun RowScope.BottomNavigationBarItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
item: NavigationItem, item: NavigationItem,
tooltipText: String,
isSelected: Boolean, isSelected: Boolean,
onClick: () -> Unit onClick: () -> Unit
) { ) {
@ -33,11 +39,11 @@ fun RowScope.BottomNavigationBarItem(
NavigationBarItem( NavigationBarItem(
label = { label = {
CustomTooltip( CustomTooltip(
text = tooltipText, text = stringResource(id = item.tooltip),
padding = 64.dp padding = 64.dp
) { ) {
Text( Text(
text = item.title, text = stringResource(id = item.title),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@ -47,11 +53,11 @@ fun RowScope.BottomNavigationBarItem(
onClick = { onClick() }, onClick = { onClick() },
icon = { icon = {
CustomTooltip( CustomTooltip(
text = tooltipText, text = stringResource(id = item.tooltip),
padding = 32.dp padding = 32.dp
) { ) {
Icon( Icon(
painter = icon, painter = painterResource(id = icon),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp) 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.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth 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 withDivider Should divider be shown or not.
* @param items The items of this dialog, use items(..) { .. } or item { .. }. * @param items The items of this dialog, use items(..) { .. } or item { .. }.
*/ */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CustomDialogWithLazyColumn( fun CustomDialogWithLazyColumn(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View file

@ -22,11 +22,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection 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 androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.domain.model.NavigationItem
import ua.acclorite.book_story.presentation.data.LocalNavigator import ua.acclorite.book_story.presentation.data.LocalNavigator
import ua.acclorite.book_story.presentation.data.Screen import ua.acclorite.book_story.presentation.data.Screen
@ -69,38 +66,13 @@ fun BoxScope.CustomNavigationRail() {
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
CustomNavigationRailItem( Constants.NAVIGATION_ITEMS.forEach {
item = NavigationItem( CustomNavigationRailItem(
stringResource(id = R.string.library_screen), item = it,
selectedIcon = painterResource(id = R.drawable.library_screen_filled), isSelected = currentScreen == it.screen
unselectedIcon = painterResource(id = R.drawable.library_screen_outlined) ) {
), navigator.navigate(it.screen, false)
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)
} }
} }
} }

View file

@ -7,6 +7,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.model.NavigationItem import ua.acclorite.book_story.domain.model.NavigationItem
@ -14,12 +16,16 @@ import ua.acclorite.book_story.presentation.components.CustomTooltip
/** /**
* Custom Navigation Rail item. * Custom Navigation Rail item.
*
* @param modifier Modifier.
* @param item [NavigationItem].
* @param isSelected Whether this screen is currently selected.
* @param onClick OnClick callback.
*/ */
@Composable @Composable
fun CustomNavigationRailItem( fun CustomNavigationRailItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
item: NavigationItem, item: NavigationItem,
tooltipText: String,
isSelected: Boolean, isSelected: Boolean,
onClick: () -> Unit onClick: () -> Unit
) { ) {
@ -31,11 +37,11 @@ fun CustomNavigationRailItem(
NavigationRailItem( NavigationRailItem(
label = { label = {
CustomTooltip( CustomTooltip(
text = tooltipText, text = stringResource(id = item.tooltip),
padding = 48.dp padding = 48.dp
) { ) {
Text( Text(
text = item.title, text = stringResource(id = item.title),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@ -45,11 +51,11 @@ fun CustomNavigationRailItem(
onClick = { onClick() }, onClick = { onClick() },
icon = { icon = {
CustomTooltip( CustomTooltip(
text = tooltipText, text = stringResource(id = item.tooltip),
padding = 16.dp padding = 16.dp
) { ) {
Icon( Icon(
painter = icon, painter = painterResource(id = icon),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp) 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 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 package ua.acclorite.book_story.presentation.data
import ua.acclorite.book_story.domain.model.LanguageHistory
sealed class MainEvent { sealed class MainEvent {
data class OnChangeLanguage(val lang: String) : MainEvent() data class OnChangeLanguage(val lang: String) : MainEvent()
data class OnChangeTheme(val theme: 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 OnChangeShowStartScreen(val bool: Boolean) : MainEvent()
data class OnChangeCheckForUpdates(val bool: Boolean) : MainEvent() data class OnChangeCheckForUpdates(val bool: Boolean) : MainEvent()
data class OnChangeSidePadding(val sidePadding: Int) : 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 android.os.Parcelable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color 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.Constants
import ua.acclorite.book_story.domain.util.DataStoreConstants import ua.acclorite.book_story.domain.util.DataStoreConstants
import ua.acclorite.book_story.presentation.ui.DarkTheme 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 ua.acclorite.book_story.presentation.ui.toThemeContrast
import java.util.Locale 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 @Immutable
data class MainState( data class MainState(
@ -38,7 +55,13 @@ data class MainState(
val showStartScreen: Boolean? = null, val showStartScreen: Boolean? = null,
val checkForUpdates: Boolean? = null, val checkForUpdates: Boolean? = null,
val sidePadding: Int? = 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 { ) : Parcelable {
constructor(parcel: Parcel) : this( constructor(parcel: Parcel) : this(
// String // String
language = parcel.readString(), language = parcel.readString(),
@ -75,6 +98,19 @@ data class MainState(
.toInt() == 1 else null, .toInt() == 1 else null,
// Int // Int
sidePadding = if (parcel.readByte().toInt() != 0) parcel.readInt() else null, 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) { override fun writeToParcel(parcel: Parcel, flags: Int) {
@ -117,9 +153,21 @@ data class MainState(
// Check For Updates // Check For Updates
parcel.writeByte((if (checkForUpdates != null) 1 else 0).toByte()) parcel.writeByte((if (checkForUpdates != null) 1 else 0).toByte())
checkForUpdates?.let { parcel.writeByte(if (it) 1 else 0) } checkForUpdates?.let { parcel.writeByte(if (it) 1 else 0) }
// Side padding // Side Padding
parcel.writeByte((if (sidePadding != null) 1 else 0).toByte()) parcel.writeByte((if (sidePadding != null) 1 else 0).toByte())
sidePadding?.let { parcel.writeInt(it) } 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 { override fun describeContents(): Int {
@ -140,7 +188,6 @@ data class MainState(
*/ */
fun initialize(data: Map<String, Any>): MainState { fun initialize(data: Map<String, Any>): MainState {
DataStoreConstants.apply { DataStoreConstants.apply {
val language: String = data[LANGUAGE.name] as? String ?: if ( val language: String = data[LANGUAGE.name] as? String ?: if (
Constants.LANGUAGES.any { Locale.getDefault().language.take(2) == it.first } 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 val sidePadding: Int = data[SIDE_PADDING.name] as? Int
?: 6 ?: 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( return MainState(
language = language, language = language,
theme = theme.toTheme(), theme = theme.toTheme(),
@ -211,7 +282,12 @@ data class MainState(
paragraphHeight = paragraphHeight, paragraphHeight = paragraphHeight,
paragraphIndentation = paragraphIndentation, paragraphIndentation = paragraphIndentation,
checkForUpdates = checkForUpdates, 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 package ua.acclorite.book_story.presentation.screens.about.data
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.data.remote.dto.ReleaseResponse import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo
@Immutable @Immutable
data class AboutState( data class AboutState(
val showUpdateDialog: Boolean = false, 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.padding
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme 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.domain.util.Constants
import ua.acclorite.book_story.presentation.components.GoBackButton import ua.acclorite.book_story.presentation.components.GoBackButton
import ua.acclorite.book_story.presentation.components.collapsibleUntilExitScrollBehaviorWithLazyListState 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.LocalNavigator
import ua.acclorite.book_story.presentation.data.Navigator import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.screens.about.data.AboutEvent import ua.acclorite.book_story.presentation.screens.about.data.AboutEvent
@ -78,7 +78,7 @@ private fun CreditsScreen(
.padding(top = paddingValues.calculateTopPadding()), .padding(top = paddingValues.calculateTopPadding()),
state = scrollState.second state = scrollState.second
) { ) {
items(Constants.CREDITS, key = { it.name }) { customItems(Constants.CREDITS, key = { it.name }) {
CreditItem(credit = it) { CreditItem(credit = it) {
onAboutNavigateEvent( onAboutNavigateEvent(
AboutEvent.OnNavigateToBrowserPage( AboutEvent.OnNavigateToBrowserPage(

View file

@ -23,6 +23,11 @@ import ua.acclorite.book_story.domain.model.Credit
/** /**
* Credit Item. * Credit Item.
*
* @param credit [Credit] item.
* @param horizontalPadding Horizontal item padding.
* @param verticalPadding Vertical item padding.
* @param onClick OnClick callback.
*/ */
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@ -67,7 +72,7 @@ fun CreditItem(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(100)) .clip(RoundedCornerShape(100))
.background(MaterialTheme.colorScheme.primary) .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(100))
.padding(vertical = 4.dp, horizontal = 8.dp) .padding(vertical = 4.dp, horizontal = 8.dp)
) )
} }

View file

@ -183,7 +183,10 @@ private fun LicenseInfoScreen(
.clickable(enabled = license.licenseContent?.isNotBlank() == true) { .clickable(enabled = license.licenseContent?.isNotBlank() == true) {
showed.value = !showed.value showed.value = !showed.value
} }
.background(MaterialTheme.colorScheme.primary) .background(
MaterialTheme.colorScheme.primary,
RoundedCornerShape(100)
)
.padding(vertical = 6.dp, horizontal = 12.dp) .padding(vertical = 6.dp, horizontal = 12.dp)
) )

View file

@ -27,6 +27,11 @@ import com.mikepenz.aboutlibraries.ui.compose.m3.util.author
/** /**
* License Item. * License Item.
*
* @param modifier Modifier.
* @param library [Library] item.
* @param horizontalPadding Horizontal item padding.
* @param verticalPadding Vertical item padding.
*/ */
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@ -85,7 +90,7 @@ fun LicenseItem(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(100)) .clip(RoundedCornerShape(100))
.background(MaterialTheme.colorScheme.primary) .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(100))
.padding(vertical = 4.dp, horizontal = 8.dp) .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 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@ -65,7 +68,10 @@ fun BookInfoInfoSection(
.height(195.dp) .height(195.dp)
.width(130.dp) .width(130.dp)
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow) .background(
MaterialTheme.colorScheme.surfaceContainerLow,
MaterialTheme.shapes.medium
)
.combinedClickable( .combinedClickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,

View file

@ -5,18 +5,11 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HideImage import androidx.compose.material.icons.filled.HideImage
import androidx.compose.material.icons.filled.ImageSearch 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.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R 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.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoState import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoState
import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent 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. * 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 @Composable
fun BookInfoChangeCoverBottomSheet( fun BookInfoChangeCoverBottomSheet(
state: State<BookInfoState>, state: State<BookInfoState>,
@ -41,8 +39,6 @@ fun BookInfoChangeCoverBottomSheet(
onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val navigationBarPadding =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val photoPicker = rememberLauncherForActivityResult( val photoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(), contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri -> onResult = { uri ->
@ -66,16 +62,12 @@ fun BookInfoChangeCoverBottomSheet(
} }
) )
ModalBottomSheet( CustomBottomSheet(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onDismissRequest = { onDismissRequest = {
onEvent(BookInfoEvent.OnShowHideChangeCoverBottomSheet) onEvent(BookInfoEvent.OnShowHideChangeCoverBottomSheet)
}, }
sheetState = rememberModalBottomSheetState(true), ) { padding ->
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
) {
BookInfoChangeCoverBottomSheetItem( BookInfoChangeCoverBottomSheetItem(
icon = Icons.Default.ImageSearch, icon = Icons.Default.ImageSearch,
text = stringResource(id = R.string.change_cover), text = stringResource(id = R.string.change_cover),
@ -110,7 +102,7 @@ fun BookInfoChangeCoverBottomSheet(
Spacer( Spacer(
modifier = Modifier.height( 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 android.widget.Toast
import androidx.compose.foundation.layout.Spacer 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.fillMaxWidth
import androidx.compose.foundation.layout.height 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.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.remember 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R 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.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoState import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoState
import java.io.File 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. * 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 @Composable
fun BookInfoDetailsBottomSheet( fun BookInfoDetailsBottomSheet(
state: State<BookInfoState>, state: State<BookInfoState>,
onEvent: (BookInfoEvent) -> Unit onEvent: (BookInfoEvent) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val navigationBarPadding =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val pattern = remember { val pattern = remember {
SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault()) SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault())
@ -66,14 +60,11 @@ fun BookInfoDetailsBottomSheet(
else "" else ""
} }
ModalBottomSheet( CustomBottomSheet(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onDismissRequest = { onDismissRequest = {
onEvent(BookInfoEvent.OnShowHideDetailsBottomSheet) onEvent(BookInfoEvent.OnShowHideDetailsBottomSheet)
}, }
sheetState = rememberModalBottomSheetState(true),
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
) { ) {
BookInfoDetailsBottomSheetItem( BookInfoDetailsBottomSheetItem(
title = stringResource(id = R.string.file_name), title = stringResource(id = R.string.file_name),
@ -151,7 +142,7 @@ fun BookInfoDetailsBottomSheet(
Spacer( Spacer(
modifier = Modifier.height( 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 androidx.compose.ui.focus.FocusRequester
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.presentation.data.Navigator import ua.acclorite.book_story.presentation.data.Navigator
@Immutable @Immutable
@ -58,7 +57,7 @@ sealed class BookInfoEvent {
data object OnDismissConfirmUpdateDialog : BookInfoEvent() data object OnDismissConfirmUpdateDialog : BookInfoEvent()
data class OnShowConfirmUpdateDialog( data class OnShowConfirmUpdateDialog(
val updatedBook: Pair<Book, List<StringWithId>>, val updatedBook: Pair<Book, List<String>>,
val authorUpdated: Boolean, val authorUpdated: Boolean,
val descriptionUpdated: Boolean, val descriptionUpdated: Boolean,
val textUpdated: 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 androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.domain.util.Constants
@Immutable @Immutable
@ -13,7 +12,7 @@ data class BookInfoState(
val isLoadingUpdate: Boolean = false, val isLoadingUpdate: Boolean = false,
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val showConfirmUpdateDialog: 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 authorChanged: Boolean = false,
val descriptionChanged: Boolean = false, val descriptionChanged: Boolean = false,
val textChanged: Boolean = false, val textChanged: Boolean = false,

View file

@ -386,7 +386,7 @@ class BookInfoViewModel @Inject constructor(
val updatedText = updatedBook.text val updatedText = updatedBook.text
val text = getText.execute(book.textPath) val text = getText.execute(book.textPath)
if (updatedText.map { it.line } != text.map { it.line }) { if (updatedText != text.map { it.value.originalLine }) {
textUpdated = true textUpdated = true
} }

View file

@ -22,6 +22,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -41,6 +42,12 @@ import java.util.Locale
/** /**
* Browse list element item. Can be selected. * 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@ -52,12 +59,12 @@ fun BrowseFileItem(
onLongClick: () -> Unit onLongClick: () -> Unit
) { ) {
val pattern = remember { SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault()) } val pattern = remember { SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault()) }
val lastModified = remember { pattern.format(Date(file.first.lastModified())) } val lastModified = rememberSaveable { pattern.format(Date(file.first.lastModified())) }
val sizeBytes = remember { file.first.length() } val sizeBytes = rememberSaveable { file.first.length() }
val fileSizeKB = remember { if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0 } val fileSizeKB = rememberSaveable { if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0 }
val fileSizeMB = remember { if (sizeBytes > 0) fileSizeKB / 1024.0 else 0.0 } val fileSizeMB = rememberSaveable { if (sizeBytes > 0) fileSizeKB / 1024.0 else 0.0 }
val fileSize = remember { val fileSize = rememberSaveable {
if (fileSizeMB >= 1.0) "%.2f MB".format(fileSizeMB) if (fileSizeMB >= 1.0) "%.2f MB".format(fileSizeMB)
else if (fileSizeMB > 0.0) "%.2f KB".format(fileSizeKB) else if (fileSizeMB > 0.0) "%.2f KB".format(fileSizeKB)
else "0 KB" else "0 KB"
@ -74,7 +81,7 @@ fun BrowseFileItem(
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 3.dp) .padding(horizontal = 8.dp, vertical = 3.dp)
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.background(backgroundColor) .background(backgroundColor, RoundedCornerShape(10.dp))
.combinedClickable( .combinedClickable(
onLongClick = { onLongClick = {
onLongClick() onLongClick()

View file

@ -40,6 +40,14 @@ import java.util.Locale
/** /**
* History list element item. * 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 @Composable
fun HistoryItem( fun HistoryItem(
@ -73,8 +81,10 @@ fun HistoryItem(
modifier = Modifier modifier = Modifier
.height(90.dp) .height(90.dp)
.width(60.dp) .width(60.dp)
.clip(RoundedCornerShape(10.dp)) .background(
.background(MaterialTheme.colorScheme.surfaceContainerLow) MaterialTheme.colorScheme.surfaceContainerLow,
RoundedCornerShape(10.dp)
)
) { ) {
if (history.book?.coverImage != null) { if (history.book?.coverImage != null) {
CustomCoverImage( CustomCoverImage(

View file

@ -47,7 +47,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
@ -179,8 +178,10 @@ private fun LibraryScreen(
Text( Text(
text = state.value.books.size.toString(), text = state.value.books.size.toString(),
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(14.dp)) .background(
.background(MaterialTheme.colorScheme.surfaceContainer) MaterialTheme.colorScheme.surfaceContainer,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 8.dp, vertical = 3.dp), .padding(horizontal = 8.dp, vertical = 3.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp fontSize = 16.sp

View file

@ -39,6 +39,12 @@ import ua.acclorite.book_story.presentation.data.removeTrailingZero
/** /**
* Library list element item. * 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@ -86,7 +92,10 @@ fun LibraryBookItem(
.aspectRatio(1f / 1.5f) .aspectRatio(1f / 1.5f)
.fillMaxWidth() .fillMaxWidth()
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow) .background(
MaterialTheme.colorScheme.surfaceContainerLow,
MaterialTheme.shapes.medium
)
.combinedClickable( .combinedClickable(
onClick = { onClick = {
onCoverImageClick() onCoverImageClick()
@ -124,7 +133,7 @@ fun LibraryBookItem(
modifier = Modifier modifier = Modifier
.padding(6.dp) .padding(6.dp)
.clip(MaterialTheme.shapes.small) .clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.primary) .background(MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small)
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )

View file

@ -21,7 +21,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -113,8 +112,10 @@ fun LibraryTabRow(
Text( Text(
text = tabItem.second.toString(), text = tabItem.second.toString(),
modifier = Modifier modifier = Modifier
.clip(MaterialTheme.shapes.medium) .background(
.background(MaterialTheme.colorScheme.surfaceContainer) MaterialTheme.colorScheme.surfaceContainer,
MaterialTheme.shapes.medium
)
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn 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.foundation.text.selection.DisableSelection
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -32,7 +29,6 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset 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.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel 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.R
import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility
import ua.acclorite.book_story.presentation.components.CustomSelectionContainer 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.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.LocalNavigator
import ua.acclorite.book_story.presentation.data.MainEvent import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState 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.ReaderEndItem
import ua.acclorite.book_story.presentation.screens.reader.components.app_bar.ReaderBottomBar import ua.acclorite.book_story.presentation.screens.reader.components.app_bar.ReaderBottomBar
import ua.acclorite.book_story.presentation.screens.reader.components.app_bar.ReaderTopBar import ua.acclorite.book_story.presentation.screens.reader.components.app_bar.ReaderTopBar
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.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.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.ReaderEvent
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel
@ -88,12 +84,27 @@ fun ReaderScreenRoot() {
val libraryViewModel: LibraryViewModel = hiltViewModel() val libraryViewModel: LibraryViewModel = hiltViewModel()
val historyViewModel: HistoryViewModel = hiltViewModel() val historyViewModel: HistoryViewModel = hiltViewModel()
val listState = rememberLazyListState()
val loading = remember { mutableStateOf(true) } val loading = remember { mutableStateOf(true) }
val state = viewModel.state.collectAsState() val state = viewModel.state.collectAsState()
val mainState = mainViewModel.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) { LaunchedEffect(Unit) {
viewModel.init( viewModel.init(
navigator, navigator,
@ -102,17 +113,37 @@ fun ReaderScreenRoot() {
libraryViewModel.onEvent(LibraryEvent.OnUpdateBook(it)) libraryViewModel.onEvent(LibraryEvent.OnUpdateBook(it))
historyViewModel.onEvent(HistoryEvent.OnLoadList) historyViewModel.onEvent(HistoryEvent.OnLoadList)
}, },
listState = listState, onError = {
Toast.makeText(
context,
it.asString(context),
Toast.LENGTH_LONG
).show()
},
onLoaded = { onLoaded = {
loading.value = false 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( ReaderScreen(
state = state, state = state,
mainState = mainState, mainState = mainState,
listState = listState,
loading = loading, loading = loading,
navigator = navigator, navigator = navigator,
onEvent = viewModel::onEvent, onEvent = viewModel::onEvent,
@ -122,13 +153,11 @@ fun ReaderScreenRoot() {
) )
} }
@OptIn(FlowPreview::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable @Composable
private fun ReaderScreen( private fun ReaderScreen(
state: State<ReaderState>, state: State<ReaderState>,
mainState: State<MainState>, mainState: State<MainState>,
listState: LazyListState,
loading: State<Boolean>, loading: State<Boolean>,
navigator: Navigator, navigator: Navigator,
onEvent: (ReaderEvent) -> Unit, onEvent: (ReaderEvent) -> Unit,
@ -137,13 +166,9 @@ private fun ReaderScreen(
onHistoryEvent: (HistoryEvent) -> Unit, onHistoryEvent: (HistoryEvent) -> Unit,
) { ) {
val context = LocalContext.current as ComponentActivity 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) { val nestedScrollConnection = remember(state) {
object : NestedScrollConnection { object : NestedScrollConnection {
override fun onPostScroll( 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) { val fontFamily = remember(mainState.value.fontFamily) {
Constants.FONTS.find { Constants.FONTS.find {
it.id == mainState.value.fontFamily it.id == mainState.value.fontFamily
@ -190,46 +218,45 @@ private fun ReaderScreen(
} }
} }
if (state.value.showTranslatorBottomSheet) {
LaunchedEffect(canScroll) { ReaderTranslatorBottomSheet(
if (!canScroll && !state.value.showMenu && !loading.value) { state = state,
onEvent(ReaderEvent.OnShowHideMenu(context = context)) onEvent = onEvent
} )
} }
if (state.value.showLanguageBottomSheet) {
TranslatorLanguageBottomSheet(
selectedLanguage = if (state.value.languageBottomSheetTranslateFrom) {
state.value.book.translateFrom
} else state.value.book.translateTo,
LaunchedEffect(listState) { unselectedLanguage = if (!state.value.languageBottomSheetTranslateFrom) {
snapshotFlow { state.value.book.translateFrom
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } else state.value.book.translateTo,
}.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
}
translateFromSelecting = state.value.languageBottomSheetTranslateFrom,
onSelect = { from, to ->
onEvent( onEvent(
ReaderEvent.OnChangeProgress( ReaderEvent.OnChangeTranslatorSettings(
progress = progress, translateFrom = from,
navigator = navigator, translateTo = to
firstVisibleItemIndex = items.first, )
firstVisibleItemOffset = items.second, )
refreshList = { book -> onEvent(
onLibraryEvent(LibraryEvent.OnUpdateBook(book)) ReaderEvent.OnShowHideLanguageBottomSheet(
onHistoryEvent(HistoryEvent.OnUpdateBook(book)) show = false
} )
)
},
onDismiss = {
onEvent(
ReaderEvent.OnShowHideLanguageBottomSheet(
show = false
) )
) )
} }
} )
} }
if (state.value.showSettingsBottomSheet) { if (state.value.showSettingsBottomSheet) {
ReaderSettingsBottomSheet( ReaderSettingsBottomSheet(
mainState = mainState, mainState = mainState,
@ -237,6 +264,12 @@ private fun ReaderScreen(
onMainEvent = onMainEvent onMainEvent = onMainEvent
) )
} }
if (state.value.showDownloadLanguageDialog) {
ReaderDownloadLanguageDialog(
state = state,
onEvent = onEvent
)
}
Scaffold( Scaffold(
Modifier Modifier
@ -254,13 +287,13 @@ private fun ReaderScreen(
onEvent = onEvent, onEvent = onEvent,
onLibraryUpdateEvent = onLibraryEvent, onLibraryUpdateEvent = onLibraryEvent,
onHistoryUpdateEvent = onHistoryEvent, onHistoryUpdateEvent = onHistoryEvent,
listState = listState,
containerColor = systemBarsColor containerColor = systemBarsColor
) )
} }
}, },
bottomBar = { bottomBar = {
CustomAnimatedVisibility( CustomAnimatedVisibility(
modifier = Modifier.fillMaxWidth(),
visible = state.value.showMenu, visible = state.value.showMenu,
enter = slideInVertically { it }, enter = slideInVertically { it },
exit = slideOutVertically { it } exit = slideOutVertically { it }
@ -270,7 +303,6 @@ private fun ReaderScreen(
onEvent = onEvent, onEvent = onEvent,
onLibraryUpdateEvent = onLibraryEvent, onLibraryUpdateEvent = onLibraryEvent,
onHistoryUpdateEvent = onHistoryEvent, onHistoryUpdateEvent = onHistoryEvent,
listState = listState,
containerColor = systemBarsColor containerColor = systemBarsColor
) )
} }
@ -288,7 +320,7 @@ private fun ReaderScreen(
}, },
onTranslateRequested = { textToTranslate -> onTranslateRequested = { textToTranslate ->
onEvent( onEvent(
ReaderEvent.OnTranslateText( ReaderEvent.OnOpenTranslator(
textToTranslate, textToTranslate,
context, context,
noAppsFound = { noAppsFound = {
@ -318,9 +350,10 @@ private fun ReaderScreen(
} }
) { toolbarShowed -> ) { toolbarShowed ->
LazyColumn( LazyColumn(
state = listState, state = state.value.listState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(backgroundColor)
.then( .then(
if (!loading.value && toolbarShowed) { if (!loading.value && toolbarShowed) {
Modifier Modifier
@ -336,56 +369,49 @@ private fun ReaderScreen(
} else { } else {
Modifier Modifier
} }
) ),
verticalArrangement = Arrangement.spacedBy(paragraphHeight)
) { ) {
if (state.value.text.isNotEmpty()) { if (state.value.text.isNotEmpty()) {
item { item {
DisableSelection { DisableSelection {
ReaderStartItem(state = state) ReaderStartItem(state = state)
} }
Spacer(modifier = Modifier.height(18.dp))
} }
} }
customItemsIndexed( customItems(
state.value.text, key = { key -> key.id } text, key = { key -> key.first }
) { index, line -> ) { line ->
val text = remember(mainState.value.paragraphIndentation, line) { ReaderTextParagraph(
"${if (mainState.value.paragraphIndentation!!) " " else ""}${line.line}" state = state,
} id = line.first,
line = line.second,
Column( context = context,
Modifier density = density,
.background(backgroundColor) fontFamily = fontFamily,
.fillMaxWidth() fontColor = fontColor,
.padding( lineHeight = lineHeight,
top = if (index == 0) 18.dp else 0.dp, fontStyle = fontStyle,
start = sidePadding, fontSize = mainState.value.fontSize!!.sp,
end = sidePadding, sidePadding = sidePadding,
bottom = if (index == state.value.text.lastIndex) 18.dp paragraphIndentation = mainState.value.paragraphIndentation!!,
else paragraphHeight onEvent = onEvent
) )
) {
Text(
text = text,
color = fontColor,
style = TextStyle(lineBreak = LineBreak.Paragraph),
fontFamily = fontFamily.font,
fontStyle = fontStyle,
fontSize = mainState.value.fontSize!!.sp,
lineHeight = lineHeight
)
}
} }
if (state.value.text.isNotEmpty()) { if (state.value.text.isNotEmpty()) {
item { item {
Spacer(modifier = Modifier.height(18.dp))
DisableSelection { DisableSelection {
ReaderEndItem( ReaderEndItem(
state = state, state = state,
onEvent = onEvent, onEvent = onEvent,
onLibraryEvent = onLibraryEvent, onLibraryEvent = onLibraryEvent,
onHistoryUpdateEvent = onHistoryEvent, onHistoryUpdateEvent = onHistoryEvent,
listState = listState
) )
} }
} }
@ -417,7 +443,6 @@ private fun ReaderScreen(
onLibraryEvent(LibraryEvent.OnUpdateBook(it)) onLibraryEvent(LibraryEvent.OnUpdateBook(it))
onHistoryEvent(HistoryEvent.OnUpdateBook(it)) onHistoryEvent(HistoryEvent.OnUpdateBook(it))
}, },
listState = listState,
navigate = { navigate = {
it.navigateBack() it.navigateBack()
} }
@ -452,7 +477,6 @@ private fun ReaderScreen(
onLibraryEvent(LibraryEvent.OnUpdateBook(it)) onLibraryEvent(LibraryEvent.OnUpdateBook(it))
onHistoryEvent(HistoryEvent.OnUpdateBook(it)) onHistoryEvent(HistoryEvent.OnUpdateBook(it))
}, },
listState = listState,
navigate = { navigate = {
it.navigateBack() 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.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues 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.height
import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text 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. * 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) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@ -48,7 +52,6 @@ fun ReaderEndItem(
onEvent: (ReaderEvent) -> Unit, onEvent: (ReaderEvent) -> Unit,
onLibraryEvent: (LibraryEvent) -> Unit, onLibraryEvent: (LibraryEvent) -> Unit,
onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit, onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit,
listState: LazyListState,
) { ) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val context = LocalContext.current as ComponentActivity val context = LocalContext.current as ComponentActivity
@ -62,7 +65,7 @@ fun ReaderEndItem(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.padding( .padding(
horizontal = 36.dp, horizontal = 36.dp,
@ -104,7 +107,6 @@ fun ReaderEndItem(
onLibraryEvent(LibraryEvent.OnUpdateCurrentPage(it)) onLibraryEvent(LibraryEvent.OnUpdateCurrentPage(it))
}, },
navigator = navigator, navigator = navigator,
listState = listState,
context = context context = context
) )
) )
@ -123,7 +125,6 @@ fun ReaderEndItem(
onLibraryEvent(LibraryEvent.OnUpdateBook(it)) onLibraryEvent(LibraryEvent.OnUpdateBook(it))
onHistoryUpdateEvent(HistoryEvent.OnUpdateBook(it)) onHistoryUpdateEvent(HistoryEvent.OnUpdateBook(it))
}, },
listState = listState,
navigate = { navigate = {
it.navigate(Screen.LIBRARY, true) 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.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults 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. * 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 @Composable
fun ReaderBottomBar( fun ReaderBottomBar(
@ -40,7 +45,6 @@ fun ReaderBottomBar(
onEvent: (ReaderEvent) -> Unit, onEvent: (ReaderEvent) -> Unit,
onLibraryUpdateEvent: (LibraryEvent.OnUpdateBook) -> Unit, onLibraryUpdateEvent: (LibraryEvent.OnUpdateBook) -> Unit,
onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit, onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit,
listState: LazyListState,
containerColor: Color containerColor: Color
) { ) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
@ -79,13 +83,13 @@ fun ReaderBottomBar(
value = state.value.book.progress, value = state.value.book.progress,
enabled = !state.value.lockMenu, enabled = !state.value.lockMenu,
onValueChange = { onValueChange = {
if (listState.layoutInfo.totalItemsCount > 0) { if (state.value.listState.layoutInfo.totalItemsCount > 0) {
onEvent(ReaderEvent.OnScroll(listState, it)) onEvent(ReaderEvent.OnScroll(it))
onEvent( onEvent(
ReaderEvent.OnChangeProgress( ReaderEvent.OnChangeProgress(
progress = it, progress = it,
navigator = navigator, navigator = navigator,
firstVisibleItemIndex = listState.firstVisibleItemIndex, firstVisibleItemIndex = state.value.listState.firstVisibleItemIndex,
firstVisibleItemOffset = 0, firstVisibleItemOffset = 0,
refreshList = { book -> refreshList = { book ->
onLibraryUpdateEvent(LibraryEvent.OnUpdateBook(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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@ -39,6 +40,12 @@ import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState
/** /**
* Reader top bar. Displays title of the book. * 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -47,7 +54,6 @@ fun ReaderTopBar(
onEvent: (ReaderEvent) -> Unit, onEvent: (ReaderEvent) -> Unit,
onLibraryUpdateEvent: (LibraryEvent.OnUpdateBook) -> Unit, onLibraryUpdateEvent: (LibraryEvent.OnUpdateBook) -> Unit,
onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit, onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit,
listState: LazyListState,
containerColor: Color containerColor: Color
) { ) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
@ -78,7 +84,6 @@ fun ReaderTopBar(
onLibraryUpdateEvent(LibraryEvent.OnUpdateBook(it)) onLibraryUpdateEvent(LibraryEvent.OnUpdateBook(it))
onHistoryUpdateEvent(HistoryEvent.OnUpdateBook(it)) onHistoryUpdateEvent(HistoryEvent.OnUpdateBook(it))
}, },
listState = listState,
navigate = { navigate = {
it.navigateBack() it.navigateBack()
} }
@ -93,6 +98,7 @@ fun ReaderTopBar(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 20.sp, fontSize = 20.sp,
lineHeight = 20.sp,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
modifier = Modifier modifier = Modifier
@ -109,7 +115,6 @@ fun ReaderTopBar(
onLibraryUpdateEvent(LibraryEvent.OnUpdateBook(it)) onLibraryUpdateEvent(LibraryEvent.OnUpdateBook(it))
onHistoryUpdateEvent(HistoryEvent.OnUpdateBook(it)) onHistoryUpdateEvent(HistoryEvent.OnUpdateBook(it))
}, },
listState = listState,
navigate = { navigate = {
it.navigateWithoutBackStack( it.navigateWithoutBackStack(
Screen.BOOK_INFO, Screen.BOOK_INFO,
@ -121,7 +126,7 @@ fun ReaderTopBar(
} }
) )
.basicMarquee( .basicMarquee(
delayMillis = 4000 iterations = Int.MAX_VALUE
) )
) )
Text( Text(
@ -135,6 +140,17 @@ fun ReaderTopBar(
} }
}, },
actions = { 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( CustomIconButton(
icon = Icons.Default.Settings, icon = Icons.Default.Settings,
contentDescription = R.string.open_reader_settings_content_desc, 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.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer 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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext 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.model.ButtonItem
import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.domain.util.Constants
import ua.acclorite.book_story.presentation.components.CategoryTitle 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.MainEvent
import ua.acclorite.book_story.presentation.data.MainState import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent 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. * Settings bottom sheet. Has General and Colors categories.
*
* @param mainState [MainState].
* @param onEvent [ReaderEvent] callback.
* @param onMainEvent [MainEvent] callback.
*/ */
@OptIn( @OptIn(ExperimentalMaterial3Api::class)
ExperimentalMaterial3Api::class
)
@Composable @Composable
fun ReaderSettingsBottomSheet( fun ReaderSettingsBottomSheet(
mainState: State<MainState>, mainState: State<MainState>,
@ -62,9 +57,6 @@ fun ReaderSettingsBottomSheet(
val currentPage by remember { derivedStateOf { pagerState.currentPage } } val currentPage by remember { derivedStateOf { pagerState.currentPage } }
val context = LocalContext.current val context = LocalContext.current
val navigationBarPadding =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val fontFamily = remember(mainState.value.fontFamily) { val fontFamily = remember(mainState.value.fontFamily) {
Constants.FONTS.find { Constants.FONTS.find {
it.id == mainState.value.fontFamily it.id == mainState.value.fontFamily
@ -98,210 +90,205 @@ fun ReaderSettingsBottomSheet(
) )
} }
Box(modifier = Modifier.fillMaxSize()) { CustomBottomSheet(
ModalBottomSheet( hasFixedHeight = true,
scrimColor = animatedScrimColor, scrimColor = animatedScrimColor,
dragHandle = {}, modifier = Modifier
modifier = Modifier .fillMaxWidth()
.align(Alignment.BottomCenter) .fillMaxHeight(animatedHeight),
.fillMaxWidth() dragHandle = {},
.fillMaxHeight(animatedHeight), onDismissRequest = {
onDismissRequest = { onEvent(ReaderEvent.OnShowHideSettingsBottomSheet)
onEvent(ReaderEvent.OnShowHideSettingsBottomSheet) }
}, ) {
sheetState = rememberModalBottomSheetState(true), ReaderSettingsBottomSheetTabRow(
contentWindowInsets = { WindowInsets(0, 0, 0, 0) }, onEvent = onEvent,
containerColor = MaterialTheme.colorScheme.surfaceContainerLow pagerState = pagerState
) { )
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()
)
)
}
)
}
}
HorizontalPager(state = pagerState) { page ->
LazyColumn {
if (page == 0) {
item { item {
Spacer( Spacer(modifier = Modifier.height(22.dp))
modifier = Modifier.height( }
8.dp + navigationBarPadding 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. * Reader start item. Displays at the beginning of the book.
*
* @param state [ReaderState].
*/ */
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@ -76,8 +78,10 @@ fun ReaderStartItem(state: State<ReaderState>) {
modifier = Modifier modifier = Modifier
.height(150.dp) .height(150.dp)
.width(100.dp) .width(100.dp)
.clip(RoundedCornerShape(10.dp)) .background(
.background(MaterialTheme.colorScheme.surfaceContainerLow) MaterialTheme.colorScheme.surfaceContainerLow,
RoundedCornerShape(10.dp)
)
) { ) {
if (state.value.book.coverImage != null) { if (state.value.book.coverImage != null) {
CustomCoverImage( 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 package ua.acclorite.book_story.presentation.screens.reader.data
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.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 import ua.acclorite.book_story.presentation.data.Navigator
@Immutable @Immutable
sealed class ReaderEvent { sealed class ReaderEvent {
data class OnTextIsEmpty(val onLoaded: () -> Unit) : ReaderEvent() data class OnTextIsEmpty(val onLoaded: () -> Unit) : ReaderEvent()
data class OnLoadText( data class OnLoadText(
val listState: LazyListState,
val refreshList: (Book) -> Unit, val refreshList: (Book) -> Unit,
val onLoaded: () -> Unit, val onLoaded: () -> Unit,
val onError: (UIText) -> Unit,
val onTextIsEmpty: () -> Unit val onTextIsEmpty: () -> Unit
) : ReaderEvent() ) : ReaderEvent()
data class OnShowHideMenu(val show: Boolean? = null, val context: ComponentActivity) : data class OnShowHideMenu(val show: Boolean? = null, val context: ComponentActivity) :
ReaderEvent() 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( data class OnGoBack(
val context: ComponentActivity, val context: ComponentActivity,
val navigator: Navigator, val navigator: Navigator,
val listState: LazyListState,
val refreshList: (Book) -> Unit, val refreshList: (Book) -> Unit,
val navigate: (Navigator) -> Unit val navigate: (Navigator) -> Unit
) : ReaderEvent() ) : ReaderEvent()
@ -36,18 +77,17 @@ sealed class ReaderEvent {
val refreshList: (Book) -> Unit val refreshList: (Book) -> Unit
) : ReaderEvent() ) : ReaderEvent()
data class OnScroll(val listState: LazyListState, val progress: Float) : ReaderEvent() data class OnScroll(val progress: Float) : ReaderEvent()
data object OnShowHideSettingsBottomSheet : ReaderEvent() data object OnShowHideSettingsBottomSheet : ReaderEvent()
data class OnScrollToSettingsPage(val page: Int, val pagerState: PagerState) : ReaderEvent() data class OnScrollToSettingsPage(val page: Int, val pagerState: PagerState) : ReaderEvent()
data class OnMoveBookToAlreadyRead( data class OnMoveBookToAlreadyRead(
val context: ComponentActivity, val context: ComponentActivity,
val listState: LazyListState,
val onUpdateCategories: (Book) -> Unit, val onUpdateCategories: (Book) -> Unit,
val updatePage: (Int) -> Unit, val updatePage: (Int) -> Unit,
val navigator: Navigator val navigator: Navigator
) : ReaderEvent() ) : ReaderEvent()
data class OnTranslateText( data class OnOpenTranslator(
val textToTranslate: String, val textToTranslate: String,
val context: ComponentActivity, val context: ComponentActivity,
val noAppsFound: () -> Unit val noAppsFound: () -> Unit

View file

@ -1,23 +1,35 @@
package ua.acclorite.book_story.presentation.screens.reader.data package ua.acclorite.book_story.presentation.screens.reader.data
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.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.Constants
import ua.acclorite.book_story.domain.util.ID
import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.domain.util.UIText
@Immutable @Immutable
data class ReaderState( data class ReaderState(
val book: Book = Constants.EMPTY_BOOK, val book: Book = Constants.EMPTY_BOOK,
val text: List<StringWithId> = emptyList(), val text: Map<ID, LineWithTranslation> = emptyMap(),
val words: Int = 0, val words: Int = 0,
val letters: 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 errorMessage: UIText? = null,
val showMenu: Boolean = false, val showMenu: Boolean = false,
val lockMenu: Boolean = false, val lockMenu: Boolean = false,
val showTranslatorBottomSheet: Boolean = false,
val showLanguageBottomSheet: Boolean = false,
val languageBottomSheetTranslateFrom: Boolean = false,
val showSettingsBottomSheet: Boolean = false, val showSettingsBottomSheet: Boolean = false,
val currentPage: Int = 0, val currentPage: Int = 0,
) )

View file

@ -15,4 +15,9 @@ sealed class SettingsEvent {
val notificationsPermissionState: PermissionState, val notificationsPermissionState: PermissionState,
val onChangeCheckForUpdates: (Boolean) -> Unit val onChangeCheckForUpdates: (Boolean) -> Unit
) : SettingsEvent() ) : 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay 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.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import javax.inject.Inject import javax.inject.Inject
@ -21,6 +24,9 @@ class SettingsViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(SettingsState())
val state = _state.asStateFlow()
private var notificationsPermissionJob: Job? = null private var notificationsPermissionJob: Job? = null
fun onEvent(event: SettingsEvent) { 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 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 @Composable
fun AppearanceSettingsThemeSwitcherItem( fun AppearanceSettingsThemeSwitcherItem(
@ -69,7 +76,8 @@ fun AppearanceSettingsThemeSwitcherItem(
onClick() onClick()
} }
.background( .background(
colorScheme.surface colorScheme.surface,
MaterialTheme.shapes.large
) )
.border( .border(
4.dp, 4.dp,
@ -87,10 +95,9 @@ fun AppearanceSettingsThemeSwitcherItem(
) { ) {
Box( Box(
Modifier Modifier
.clip(RoundedCornerShape(10.dp))
.height(20.dp) .height(20.dp)
.width(80.dp) .width(80.dp)
.background(colorScheme.onSurface) .background(colorScheme.onSurface, RoundedCornerShape(10.dp))
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
@ -117,9 +124,9 @@ fun AppearanceSettingsThemeSwitcherItem(
.padding(start = 8.dp) .padding(start = 8.dp)
.height(80.dp) .height(80.dp)
.width(70.dp) .width(70.dp)
.clip(RoundedCornerShape(14.dp))
.background( .background(
colorScheme.surfaceContainer colorScheme.surfaceContainer,
RoundedCornerShape(14.dp)
) )
) )
Spacer(modifier = Modifier.height(40.dp)) Spacer(modifier = Modifier.height(40.dp))
@ -135,16 +142,14 @@ fun AppearanceSettingsThemeSwitcherItem(
) { ) {
Box( Box(
Modifier Modifier
.clip(CircleShape)
.size(20.dp) .size(20.dp)
.background(colorScheme.primary) .background(colorScheme.primary, CircleShape)
) )
Box( Box(
Modifier Modifier
.clip(RoundedCornerShape(10.dp))
.height(20.dp) .height(20.dp)
.width(60.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.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp 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.CategoryTitle
import ua.acclorite.book_story.presentation.components.GoBackButton import ua.acclorite.book_story.presentation.components.GoBackButton
import ua.acclorite.book_story.presentation.components.collapsibleUntilExitScrollBehaviorWithLazyListState 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.LocalNavigator
import ua.acclorite.book_story.presentation.data.MainEvent import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainState import ua.acclorite.book_story.presentation.data.MainState
import ua.acclorite.book_story.presentation.data.MainViewModel import ua.acclorite.book_story.presentation.data.MainViewModel
import ua.acclorite.book_story.presentation.data.Navigator 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.ChipsWithTitle
import ua.acclorite.book_story.presentation.screens.settings.components.SliderWithTitle 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.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 @Composable
fun ReaderSettingsRoot() { fun ReaderSettingsRoot() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val mainViewModel: MainViewModel = hiltViewModel() val mainViewModel: MainViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel()
val state = mainViewModel.state.collectAsState() val state = mainViewModel.state.collectAsState()
val settingsState = settingsViewModel.state.collectAsState()
ReaderSettings( ReaderSettings(
state = state, state = state,
settingsState = settingsState,
navigator = navigator, navigator = navigator,
onMainEvent = mainViewModel::onEvent onMainEvent = mainViewModel::onEvent,
onEvent = settingsViewModel::onEvent
) )
} }
@ -57,16 +68,70 @@ fun ReaderSettingsRoot() {
@Composable @Composable
private fun ReaderSettings( private fun ReaderSettings(
state: State<MainState>, state: State<MainState>,
settingsState: State<SettingsState>,
navigator: Navigator, navigator: Navigator,
onMainEvent: (MainEvent) -> Unit onMainEvent: (MainEvent) -> Unit,
onEvent: (SettingsEvent) -> Unit
) { ) {
val scrollState = TopAppBarDefaults.collapsibleUntilExitScrollBehaviorWithLazyListState() val scrollState = TopAppBarDefaults.collapsibleUntilExitScrollBehaviorWithLazyListState()
val context = LocalContext.current
val fontFamily = remember(state.value.fontFamily) { val fontFamily = remember(state.value.fontFamily) {
Constants.FONTS.find { Constants.FONTS.find {
it.id == state.value.fontFamily it.id == state.value.fontFamily
} ?: Constants.FONTS[0] } ?: 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( Scaffold(
Modifier Modifier
@ -99,6 +164,91 @@ private fun ReaderSettings(
item { item {
Spacer(modifier = Modifier.height(16.dp)) 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 { item {
CategoryTitle( CategoryTitle(
title = stringResource(id = R.string.font_reader_settings), title = stringResource(id = R.string.font_reader_settings),
@ -108,6 +258,7 @@ private fun ReaderSettings(
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
item { item {
ChipsWithTitle( ChipsWithTitle(
title = stringResource(id = R.string.font_family_option), 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 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) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
@ -123,7 +130,10 @@ fun StartSettings(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(MaterialTheme.shapes.large) .clip(MaterialTheme.shapes.large)
.background(MaterialTheme.colorScheme.surfaceContainerLow), .background(
MaterialTheme.colorScheme.surfaceContainerLow,
MaterialTheme.shapes.large
),
) { ) {
StartNavigationScreenItem( StartNavigationScreenItem(
state = state, 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="move_book">Перемістити книгу?</string>
<string name="delete_history">Видалити історію читання?</string> <string name="delete_history">Видалити історію читання?</string>
<string name="confirm_update">Підтвердити оновлення?</string> <string name="confirm_update">Підтвердити оновлення?</string>
<string name="download_language">Завантажити %1$s?</string>
<string name="download_languages">Завантажити мови?</string>
<!-- Dialog Descriptions --> <!-- Dialog Descriptions -->
<string name="storage_permission_description"> <string name="storage_permission_description">
@ -78,6 +80,17 @@
Натиснувши на кнопку «Завантажити», ви перейдете в ваш браузер Натиснувши на кнопку «Завантажити», ви перейдете в ваш браузер
та завантажете останнє оновлення автоматично. та завантажете останнє оновлення автоматично.
</string> </string>
<string name="download_language_description">
Вам потрібно завантажити цю мову перед використанням.
Після завантаження, вона буде доступна офлайн та не потребуватиме інтернету.
Кожна мова має розмір близько ~30мб, для завантаження рекомендується використовувати Wi-Fi.
</string>
<string name="download_languages_description">
Вам потрібно завантажити ці мови перед використанням.
Кожна мова має розмір близько ~30мб, для завантаження рекомендується використовувати Wi-Fi.
Ви можете вибрати мови, які ви хочете завантажити,
всі не вибрані мови не будуть перекладені.
</string>
<!-- Errors --> <!-- Errors -->
<string name="error_permission"> <string name="error_permission">
@ -104,6 +117,12 @@
Щось пішло не так. Щось пішло не так.
Спробуйте пізніше. Спробуйте пізніше.
</string> </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 --> <!-- Is Empty messages -->
<string name="browse_empty"> <string name="browse_empty">
@ -116,6 +135,9 @@
Нехай ваша остання прочитана книга відкриє перед Нехай ваша остання прочитана книга відкриє перед
вами сторінки нової великої історії вашого читацького шляху вами сторінки нової великої історії вашого читацького шляху
</string> </string>
<string name="translator_languages_empty">
Не існує мови з такою назвою, або вона не підтримується
</string>
<!-- Query strings --> <!-- Query strings -->
<string name="selected_items_count_query">%1$s обрано</string> <string name="selected_items_count_query">%1$s обрано</string>
@ -150,11 +172,14 @@
<string name="no">Ні</string> <string name="no">Ні</string>
<string name="download">Завантажити</string> <string name="download">Завантажити</string>
<string name="done">Готово</string> <string name="done">Готово</string>
<string name="undo_translation">Відмінити</string>
<string name="continue_download">Далі</string>
<!-- Items --> <!-- Items -->
<string name="files">файли</string> <string name="files">файли</string>
<string name="books">книги</string> <string name="books">книги</string>
<string name="history">історію</string> <string name="history">історію</string>
<string name="languages">мови</string>
<!-- Book --> <!-- Book -->
<string name="unknown_author">Невідомий</string> <string name="unknown_author">Невідомий</string>
@ -181,13 +206,14 @@
<string name="general_settings">Головні</string> <string name="general_settings">Головні</string>
<string name="general_settings_desc">Мова застосунка</string> <string name="general_settings_desc">Мова застосунка</string>
<string name="appearance_settings">Зовнішній вигляд</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">Читач</string>
<string name="reader_settings_desc">Налаштування шрифта, налаштування текста</string> <string name="reader_settings_desc">Перекладач, шрифт, текст</string>
<!-- Settings subcategories --> <!-- Settings subcategories -->
<string name="font_reader_settings">Шрифт</string> <string name="font_reader_settings">Шрифт</string>
<string name="text_reader_settings">Текст</string> <string name="text_reader_settings">Текст</string>
<string name="translator_reader_settings">Перекладач</string>
<string name="theme_appearance_settings">Налаштування теми</string> <string name="theme_appearance_settings">Налаштування теми</string>
<string name="colors_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">Перевіряти на оновлення</string>
<string name="check_for_updates_option_desc">Перевіряти на новішу версію застосунку при запуску</string> <string name="check_for_updates_option_desc">Перевіряти на новішу версію застосунку при запуску</string>
<string name="side_padding_option">Бокові відступи</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 --> <!-- Colors -->
<string name="red_color">Червоний</string> <string name="red_color">Червоний</string>
@ -255,7 +288,12 @@
<string name="book_moved">Ця книга була успішно переміщена.</string> <string name="book_moved">Ця книга була успішно переміщена.</string>
<string name="copied">Скопійовано</string> <string name="copied">Скопійовано</string>
<string name="history_deleted">Історія була успішно видалена.</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 --> <!-- Snackbar messages -->
<string name="history_element_deleted">Елемент історії був успішно видалений.</string> <string name="history_element_deleted">Елемент історії був успішно видалений.</string>
@ -282,14 +320,25 @@
Дякуємо за те, що прочитали \"%1$s\" з нами. Дякуємо за те, що прочитали \"%1$s\" з нами.
Ми сподіваємось, що вам сподобалася ця книга! Ми сподіваємось, що вам сподобалася ця книга!
</string> </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 --> <!-- 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_1">Версія v%1$s</string>
<string name="app_version_option_desc_2">Натисніть, щоб перевірити на оновлення</string> <string name="app_version_option_desc_2">Натисніть, щоб перевірити на оновлення</string>
<string name="report_bug_option">Повідомити про помилку</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="support_option">Підтримати мою роботу</string>
<string name="licenses_option">Ліцензії з відкритим кодом</string> <string name="licenses_option">Ліцензії з відкритим кодом</string>
<string name="credits_option">Заслуги</string> <string name="credits_option">Заслуги</string>
@ -474,6 +523,8 @@
<string name="reset_start_content_desc">Скинути початковий посібник</string> <string name="reset_start_content_desc">Скинути початковий посібник</string>
<string name="hide_edit_title_content_desc">Вийти з редагування назви</string> <string name="hide_edit_title_content_desc">Вийти з редагування назви</string>
<string name="open_in_web_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> </resources>

View file

@ -55,6 +55,8 @@
<string name="move_book">Move book?</string> <string name="move_book">Move book?</string>
<string name="delete_history">Delete reading history?</string> <string name="delete_history">Delete reading history?</string>
<string name="confirm_update">Confirm update?</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 --> <!-- Dialog Descriptions -->
<string name="storage_permission_description"> <string name="storage_permission_description">
@ -97,6 +99,17 @@
By clicking the «Download» button you will be transferred By clicking the «Download» button you will be transferred
to your browser and download the latest update automatically. to your browser and download the latest update automatically.
</string> </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 --> <!-- Errors -->
<string name="error_permission"> <string name="error_permission">
@ -123,6 +136,12 @@
Something went wrong. Something went wrong.
Try again later. Try again later.
</string> </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 --> <!-- Is Empty messages -->
<string name="browse_empty"> <string name="browse_empty">
@ -134,6 +153,9 @@
<string name="history_empty"> <string name="history_empty">
Let your last book you read open the pages of a new great story of your reading journey Let your last book you read open the pages of a new great story of your reading journey
</string> </string>
<string name="translator_languages_empty">
There is no such language with this name, or it is not supported
</string>
<!-- Query strings --> <!-- Query strings -->
<string name="selected_items_count_query">%1$s selected</string> <string name="selected_items_count_query">%1$s selected</string>
@ -168,11 +190,14 @@
<string name="no">No</string> <string name="no">No</string>
<string name="download">Download</string> <string name="download">Download</string>
<string name="done">Done</string> <string name="done">Done</string>
<string name="undo_translation">Undo</string>
<string name="continue_download">Continue</string>
<!-- Items --> <!-- Items -->
<string name="files">files</string> <string name="files">files</string>
<string name="books">books</string> <string name="books">books</string>
<string name="history">history</string> <string name="history">history</string>
<string name="languages">languages</string>
<!-- Book --> <!-- Book -->
<string name="unknown_author">Unknown</string> <string name="unknown_author">Unknown</string>
@ -199,13 +224,14 @@
<string name="general_settings">General</string> <string name="general_settings">General</string>
<string name="general_settings_desc">App language</string> <string name="general_settings_desc">App language</string>
<string name="appearance_settings">Appearance</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">Reader</string>
<string name="reader_settings_desc">Font settings, text settings</string> <string name="reader_settings_desc">Translator, font, text</string>
<!-- Settings subcategories --> <!-- Settings subcategories -->
<string name="font_reader_settings">Font</string> <string name="font_reader_settings">Font</string>
<string name="text_reader_settings">Text</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="theme_appearance_settings">Theme preferences</string>
<string name="colors_appearance_settings">Colors</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">Check for updates</string>
<string name="check_for_updates_option_desc">Check for newer app version on startup</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="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 --> <!-- Colors -->
<string name="red_color">Red</string> <string name="red_color">Red</string>
@ -274,6 +307,13 @@
<string name="copied">Copied</string> <string name="copied">Copied</string>
<string name="history_deleted">History was successfully deleted.</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="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 --> <!-- Snackbar messages -->
<string name="history_element_deleted">History element was successfully deleted.</string> <string name="history_element_deleted">History element was successfully deleted.</string>
@ -302,6 +342,17 @@
</string> </string>
<string name="letters_and_words">%1$s characters, %2$s words.</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 --> <!-- About -->
<string name="app_version_option">App version</string> <string name="app_version_option">App version</string>
<string name="app_version_option_desc_1">Version v%1$s</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="reset_start_content_desc">Reset start guide</string>
<string name="hide_edit_title_content_desc">Exit title editing</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="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> </resources>

View file

@ -1,7 +1,11 @@
plugins { plugins {
id("com.android.application") version "8.2.0-rc03" apply false id("com.android.application") version "8.3.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.21" apply false id("org.jetbrains.kotlin.android") version "2.0.0" apply false
id("com.google.devtools.ksp") version "1.9.21-1.0.16" 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.google.dagger.hilt.android") version "2.50" apply false
id("com.mikepenz.aboutlibraries.plugin") version "11.1.3" 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