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