diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d8eac87..25e202f1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,10 +89,14 @@ dependencies { implementation(fileTree("libs") { include("*.jar") }) // Kotlin - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") implementation("org.jetbrains.kotlin:kotlin-script-runtime:1.6.20") implementation("org.jetbrains.kotlin:kotlin-stdlib:1.6.21") + // Needed to have the Task -> await extension. + implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.2") + + // Room components implementation("androidx.room:room-runtime:2.4.2") implementation("androidx.room:room-ktx:2.4.2") @@ -169,13 +173,15 @@ dependencies { // Coil for jetpack compose implementation("io.coil-kt:coil-compose:2.1.0") - // Compose collapsing toolbar implementation("me.onebone:toolbar-compose:2.3.3") // Compose scroll bar implementation("com.github.nanihadesuka:LazyColumnScrollbar:1.3.1") + // Android ML Translation Kit + implementation("com.google.mlkit:translate:17.0.0") + // Logging implementation("com.jakewharton.timber:timber:5.0.1") diff --git a/app/src/main/java/my/noveldokusha/App.kt b/app/src/main/java/my/noveldokusha/App.kt index e97db695..af189be7 100644 --- a/app/src/main/java/my/noveldokusha/App.kt +++ b/app/src/main/java/my/noveldokusha/App.kt @@ -2,10 +2,6 @@ package my.noveldokusha import android.app.Application import dagger.hilt.android.HiltAndroidApp -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import java.io.File import javax.inject.Inject diff --git a/app/src/main/java/my/noveldokusha/AppModule.kt b/app/src/main/java/my/noveldokusha/AppModule.kt index 3cf46e55..3b58251a 100644 --- a/app/src/main/java/my/noveldokusha/AppModule.kt +++ b/app/src/main/java/my/noveldokusha/AppModule.kt @@ -1,8 +1,6 @@ package my.noveldokusha import android.content.Context -import android.content.SharedPreferences -import androidx.preference.PreferenceManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,39 +12,41 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import my.noveldokusha.data.Repository import my.noveldokusha.data.database.AppDatabase +import my.noveldokusha.tools.TranslationManager import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module -object AppModule -{ +object AppModule { const val mainDatabaseName = "bookEntry" @Provides @Singleton - fun provideRepository(database: AppDatabase, @ApplicationContext context: Context): Repository - { + fun provideRepository(database: AppDatabase, @ApplicationContext context: Context): Repository { return Repository(database, context, mainDatabaseName) } @Provides @Singleton - fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase - { + fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { return AppDatabase.createRoom(context, mainDatabaseName) } @Provides @Singleton - fun provideAppPreferencies(@ApplicationContext context: Context): AppPreferences - { + fun provideAppPreferences(@ApplicationContext context: Context): AppPreferences { return AppPreferences(context) } @Provides @Singleton - fun provideAppCoroutineScope(): CoroutineScope - { + fun provideAppCoroutineScope(): CoroutineScope { return CoroutineScope(SupervisorJob() + Dispatchers.Main + CoroutineName("App")) } + + @Provides + @Singleton + fun provideTranslationManager(coroutineScope: CoroutineScope): TranslationManager { + return TranslationManager(coroutineScope) + } } \ No newline at end of file diff --git a/app/src/main/java/my/noveldokusha/AppPreferences.kt b/app/src/main/java/my/noveldokusha/AppPreferences.kt index e39b213f..aa2c55b7 100644 --- a/app/src/main/java/my/noveldokusha/AppPreferences.kt +++ b/app/src/main/java/my/noveldokusha/AppPreferences.kt @@ -60,6 +60,15 @@ class AppPreferences @Inject constructor( val BOOKS_LIST_LAYOUT_MODE = object : Preference("BOOKS_LIST_LAYOUT_MODE"){ override var value by SharedPreference_Enum(name,preferences, LIST_LAYOUT_MODE.verticalGrid) { enumValueOf(it) } } + val GLOBAL_TRANSLATIOR_ENABLED = object : Preference("GLOBAL_TRANSLATION_ENABLED"){ + override var value by SharedPreference_Boolean(name,preferences, false) + } + val GLOBAL_TRANSLATIOR_PREFERRED_SOURCE = object : Preference("GLOBAL_TRANSLATIOR_PREFERRED_SOURCE"){ + override var value by SharedPreference_String(name,preferences, "en") + } + val GLOBAL_TRANSLATIOR_PREFERRED_TARGET = object : Preference("GLOBAL_TRANSLATION_PREFERRED_TARGET"){ + override var value by SharedPreference_String(name,preferences, "") + } enum class TERNARY_STATE { diff --git a/app/src/main/java/my/noveldokusha/tools/TranslationManager.kt b/app/src/main/java/my/noveldokusha/tools/TranslationManager.kt new file mode 100644 index 00000000..1ebdd8b6 --- /dev/null +++ b/app/src/main/java/my/noveldokusha/tools/TranslationManager.kt @@ -0,0 +1,158 @@ +package my.noveldokusha.tools + +import androidx.compose.runtime.mutableStateListOf +import com.google.mlkit.common.model.DownloadConditions +import com.google.mlkit.common.model.RemoteModel +import com.google.mlkit.common.model.RemoteModelManager +import com.google.mlkit.nl.translate.* +import kotlinx.coroutines.* +import kotlinx.coroutines.tasks.await +import java.util.Locale + +data class TranslationModelState( + val language: String, + val model: TranslateRemoteModel?, + val downloading: Boolean, + val downloadingFailed: Boolean +) { + val locale = Locale(language) +} + +data class TranslatorState( + val translator: Translator, + val source: String, + var target: String +) { + val sourceLocale = Locale(source) + val targetLocale = Locale(target) + fun translate(input: String) = translator.translate(input) +} + +class TranslationManager( + private val coroutineScope: CoroutineScope +) { + val models = mutableStateListOf().apply { + val list = TranslateLanguage.getAllLanguages().map { + TranslationModelState( + language = it, + model = null, + downloading = false, + downloadingFailed = false + ) + } + addAll(list) + } + + val modelsDownloaded = mutableStateListOf() + + init { + coroutineScope.launch { + val downloaded = loadDownloadedModelsList().associateBy { it.language } + models.replaceAll { + it.copy(model = downloaded[it.language]) + } + updateModelsDownloaded() + } + } + + private fun updateModelsDownloaded() { + modelsDownloaded.clear() + modelsDownloaded.addAll(models.filter { it.model != null }) + } + + private suspend fun loadDownloadedModelsList() = withContext(Dispatchers.IO) { + RemoteModelManager + .getInstance() + .getDownloadedModels(TranslateRemoteModel::class.java) + .await() + } + + // TODO Should not use blocking code + fun hasModelDownloadedSync(language: String) = runBlocking { + val model = TranslateRemoteModel.Builder(language).build() + val isDownloaded = RemoteModelManager + .getInstance() + .isModelDownloaded(model) + .await() + if (isDownloaded) TranslationModelState( + model = model, + downloading = false, + language = language, + downloadingFailed = false + ) else null + } + + /** + * Doesn't check if the model has been downloaded. Must be externally guaranteed. + * @param source [TranslateLanguage] + * @param target [TranslateLanguage] + */ + fun getTranslator( + source: String, + target: String + ): TranslatorState { + val option = TranslatorOptions.Builder() + .setSourceLanguage(source) + .setTargetLanguage(target) + .build() + + return TranslatorState( + translator = Translation.getClient(option), + source = source, + target = target, + ) + } + + fun downloadModel(language: String) = coroutineScope.launch { + val index = models.indexOfFirst { it.language == language } + if (index == -1 || models[index].model != null) return@launch + + models[index] = models[index].copy( + downloadingFailed = false, + downloading = true, + ) + + RemoteModelManager + .getInstance() + .download( + TranslateRemoteModel.Builder(language).build(), + DownloadConditions.Builder().build() + ) + .addOnSuccessListener { + models[index] = models[index].copy( + downloadingFailed = false, + downloading = false, + model = TranslateRemoteModel.Builder(language).build() + ) + updateModelsDownloaded() + } + .addOnFailureListener { + models[index] = models[index].copy( + downloadingFailed = true, + downloading = false + ) + } + } + + fun removeModel(language: String) = coroutineScope.launch { + + // English can't be removed. + if (language == "en") return@launch + + val index = models.indexOfFirst { it.language == language } + if (index == -1) return@launch + val model = models[index].model ?: return@launch + + RemoteModelManager + .getInstance() + .deleteDownloadedModel(model) + .addOnSuccessListener { + models[index] = models[index].copy( + downloadingFailed = false, + downloading = false, + model = null + ) + updateModelsDownloaded() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/my/noveldokusha/ui/BaseActivity.kt b/app/src/main/java/my/noveldokusha/ui/BaseActivity.kt index 63a52e81..dc5eadce 100644 --- a/app/src/main/java/my/noveldokusha/ui/BaseActivity.kt +++ b/app/src/main/java/my/noveldokusha/ui/BaseActivity.kt @@ -15,7 +15,7 @@ import my.noveldokusha.utils.toast @AndroidEntryPoint open class BaseActivity : AppCompatActivity() { - val appPreferences: AppPreferences by lazy { AppModule.provideAppPreferencies(applicationContext) } + val appPreferences: AppPreferences by lazy { AppModule.provideAppPreferences(applicationContext) } private fun getAppTheme(): Int { diff --git a/app/src/main/java/my/noveldokusha/ui/composeViews/Section.kt b/app/src/main/java/my/noveldokusha/ui/composeViews/Section.kt new file mode 100644 index 00000000..0b36a934 --- /dev/null +++ b/app/src/main/java/my/noveldokusha/ui/composeViews/Section.kt @@ -0,0 +1,48 @@ +package my.noveldokusha.ui.composeViews + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import my.noveldokusha.ui.theme.ColorAccent + +@Composable +fun Section( + title: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Surface( + color = MaterialTheme.colors.primaryVariant, + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + ) { + Column( + modifier = Modifier.padding(vertical = 16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(bottom = 16.dp), + color = ColorAccent, + textAlign = TextAlign.Center, + ) + Divider(color = MaterialTheme.colors.secondary) + content() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/my/noveldokusha/ui/composeViews/SettingsTranslationModels.kt b/app/src/main/java/my/noveldokusha/ui/composeViews/SettingsTranslationModels.kt new file mode 100644 index 00000000..dc4b6171 --- /dev/null +++ b/app/src/main/java/my/noveldokusha/ui/composeViews/SettingsTranslationModels.kt @@ -0,0 +1,92 @@ +package my.noveldokusha.ui.composeViews + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.Done +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import my.noveldokusha.R +import my.noveldokusha.tools.TranslationModelState + +@Composable +fun SettingsTranslationModels( + translationModelsStates: List, + onDownloadTranslationModel: (lang: String) -> Unit, + onRemoveTranslationModel: (lang: String) -> Unit, +) { + Section(title = stringResource(R.string.settings_title_translation_models)) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 4.dp) + ) { + Spacer(modifier = Modifier.height(0.dp)) + Text( + text = stringResource(R.string.settings_translations_models_main_description), + modifier = Modifier.width(260.dp), + textAlign = TextAlign.Center + ) + translationModelsStates.forEach { + Row( + modifier = Modifier.padding(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = it.locale.displayLanguage, + modifier = Modifier.weight(1f) + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .widthIn(min = 22.dp) + .height(22.dp) + ) { + when { + it.model != null -> { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Outlined.Done, + contentDescription = null + ) + IconButton( + onClick = { onRemoveTranslationModel(it.language) }, + enabled = it.language != "en" + ) { + Icon( + Icons.Default.Delete, + contentDescription = null, + ) + } + } + } + it.downloading -> IconButton(onClick = { }, enabled = false) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + color = MaterialTheme.colors.onPrimary + ) + } + else -> IconButton( + onClick = { onDownloadTranslationModel(it.language) }) { + Icon( + Icons.Default.CloudDownload, + contentDescription = null, + tint = if (it.downloadingFailed) Color.Red + else LocalContentColor.current.copy(alpha = LocalContentAlpha.current) + ) + } + } + } + } + } + Spacer(modifier = Modifier.height(0.dp)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/my/noveldokusha/ui/screens/main/settings/SettingsView.kt b/app/src/main/java/my/noveldokusha/ui/screens/main/settings/SettingsView.kt index 019802ca..4199025f 100644 --- a/app/src/main/java/my/noveldokusha/ui/screens/main/settings/SettingsView.kt +++ b/app/src/main/java/my/noveldokusha/ui/screens/main/settings/SettingsView.kt @@ -22,10 +22,13 @@ fun SettingsView(context: Context) onThemeSelected = viewModel::onThemeSelected, databaseSize = viewModel.databaseSize, imagesFolderSize = viewModel.imageFolderSize, + translationModelsStates = viewModel.translationManager.models, onCleanDatabase = viewModel::cleanDatabase, onCleanImageFolder = viewModel::cleanImagesFolder, onBackupData = onDoBackup(), onRestoreData = onDoRestore(), + onDownloadTranslationModel = viewModel.translationManager::downloadModel, + onRemoveTranslationModel = viewModel.translationManager::removeModel ) } } diff --git a/app/src/main/java/my/noveldokusha/ui/screens/main/settings/SettingsViewModel.kt b/app/src/main/java/my/noveldokusha/ui/screens/main/settings/SettingsViewModel.kt index 699e2c8c..013ed276 100644 --- a/app/src/main/java/my/noveldokusha/ui/screens/main/settings/SettingsViewModel.kt +++ b/app/src/main/java/my/noveldokusha/ui/screens/main/settings/SettingsViewModel.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.mapNotNull import my.noveldokusha.* import my.noveldokusha.data.Repository +import my.noveldokusha.tools.TranslationManager import my.noveldokusha.ui.BaseViewModel import my.noveldokusha.ui.theme.Themes import java.io.File @@ -19,7 +20,8 @@ import javax.inject.Inject class SettingsViewModel @Inject constructor( private val repository: Repository, private val appScope: CoroutineScope, - private val appPreferences: AppPreferences + private val appPreferences: AppPreferences, + val translationManager: TranslationManager, ) : BaseViewModel() { fun stateCreator(theFlow: Flow, initialValue: T): MutableState diff --git a/app/src/main/java/my/noveldokusha/ui/screens/main/settings/SettingsViews.kt b/app/src/main/java/my/noveldokusha/ui/screens/main/settings/SettingsViews.kt index 69353a2d..f9540af0 100644 --- a/app/src/main/java/my/noveldokusha/ui/screens/main/settings/SettingsViews.kt +++ b/app/src/main/java/my/noveldokusha/ui/screens/main/settings/SettingsViews.kt @@ -6,12 +6,11 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.CheckCircle -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -24,6 +23,9 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import my.noveldokusha.R +import my.noveldokusha.tools.TranslationModelState +import my.noveldokusha.ui.composeViews.Section +import my.noveldokusha.ui.composeViews.SettingsTranslationModels import my.noveldokusha.ui.theme.ColorAccent import my.noveldokusha.ui.theme.InternalTheme import my.noveldokusha.ui.theme.Themes @@ -230,10 +232,13 @@ fun SettingsBody( onThemeSelected: (Themes) -> Unit, databaseSize: String, imagesFolderSize: String, + translationModelsStates: List, onCleanDatabase: () -> Unit, onCleanImageFolder: () -> Unit, onBackupData: () -> Unit, - onRestoreData: () -> Unit + onRestoreData: () -> Unit, + onDownloadTranslationModel: (lang: String) -> Unit, + onRemoveTranslationModel: (lang: String) -> Unit, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -258,6 +263,11 @@ fun SettingsBody( onBackupData = onBackupData, onRestoreData = onRestoreData ) + SettingsTranslationModels( + translationModelsStates = translationModelsStates, + onDownloadTranslationModel = onDownloadTranslationModel, + onRemoveTranslationModel = onRemoveTranslationModel + ) Spacer(modifier = Modifier.height(500.dp)) Text( text = "(°.°)", @@ -273,40 +283,7 @@ fun SettingsBody( } } -@Composable -private fun Section( - title: String, - modifier: Modifier = Modifier, - content: @Composable () -> Unit -) { - Surface( - color = MaterialTheme.colors.primaryVariant, - modifier = modifier - .clip(RoundedCornerShape(16.dp)) - ) { - Column( - modifier = Modifier.padding(vertical = 16.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.subtitle1, - fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(bottom = 16.dp), - color = ColorAccent, - textAlign = TextAlign.Center, - ) - Divider(color = MaterialTheme.colors.secondary) - content() - } - } -} - -@Preview( - device = Devices.PIXEL_4_XL -) +@Preview(device = Devices.PIXEL_4_XL) @Composable private fun Preview() { val currentTheme = Themes.DARK @@ -318,10 +295,13 @@ private fun Preview() { onThemeSelected = { }, databaseSize = "1 MB", imagesFolderSize = "10 MB", + translationModelsStates = listOf(), onCleanDatabase = { }, onCleanImageFolder = { }, onBackupData = { }, - onRestoreData = { } + onRestoreData = { }, + onDownloadTranslationModel = {}, + onRemoveTranslationModel = {}, ) } } \ No newline at end of file diff --git a/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderActivity.kt b/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderActivity.kt index 4f7f7c0e..9174a15c 100644 --- a/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderActivity.kt +++ b/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle +import android.util.Log import android.view.WindowManager import android.widget.AbsListView import androidx.activity.compose.BackHandler @@ -19,15 +20,14 @@ import androidx.core.view.doOnNextLayout import androidx.lifecycle.lifecycleScope import com.afollestad.materialdialogs.MaterialDialog import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.tasks.await import my.noveldokusha.R import my.noveldokusha.databinding.ActivityReaderBinding import my.noveldokusha.scraper.Response import my.noveldokusha.ui.BaseActivity +import my.noveldokusha.ui.screens.reader.tools.FontsLoader import my.noveldokusha.ui.theme.Theme import my.noveldokusha.utils.Extra_String import my.noveldokusha.utils.colorAttrRes @@ -76,31 +76,42 @@ class ReaderActivity : BaseActivity() { private val fontsLoader = FontsLoader() + fun reloadReader() { + viewBind.listView.isEnabled = false + val currentChapter = viewModel.currentChapter.copy( + position = viewModel.currentChapter.position + 1 // needs + 1 for some unknown reason + ) + lifecycleScope.coroutineContext.cancelChildren() + viewModel.reloadReader() + loadRestartedInitialChapter(currentChapter) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(viewBind.root) viewBind.listView.adapter = viewAdapter.listView + viewModel.onTranslatorStateChanged = { + reloadReader() + } + viewModel.initialLoad { loadInitialChapter() } viewBind.settings.setContent { Theme( appPreferences = appPreferences, wrapper = { - // Necessay so that text knows what color it must be given that the + // Necessary so that text knows what color it must be given that the // background is transparent (no Surface parent) CompositionLocalProvider( LocalContentColor provides MaterialTheme.colors.onSecondary ) { it() } } ) { - val textFont by remember { appPreferences.READER_FONT_FAMILY.state(lifecycleScope) } - val textSize by remember { appPreferences.READER_FONT_SIZE.state(lifecycleScope) } - val stats by viewModel.readingPosStats.observeAsState() - val percetage by remember { + val percentage by remember { derivedStateOf { - val (info, itemPos) = stats ?: return@derivedStateOf 0f + val (info, itemPos) = viewModel.readingPosStats ?: return@derivedStateOf 0f when (info.itemCount) { 0 -> 100f else -> ceil((itemPos.toFloat() / info.itemCount.toFloat()) * 100f) @@ -110,14 +121,18 @@ class ReaderActivity : BaseActivity() { // Notify manually text font changed for list view LaunchedEffect(true) { - snapshotFlow { textFont }.drop(1) - .collect { viewAdapter.listView.notifyDataSetChanged() } + snapshotFlow { viewModel.textFont }.drop(1) + .collect { + viewAdapter.listView.notifyDataSetChanged() + } } // Notify manually text size changed for list view LaunchedEffect(true) { - snapshotFlow { textSize }.drop(1) - .collect { viewAdapter.listView.notifyDataSetChanged() } + snapshotFlow { viewModel.textSize }.drop(1) + .collect { + viewAdapter.listView.notifyDataSetChanged() + } } // Capture back action when viewing info @@ -125,18 +140,18 @@ class ReaderActivity : BaseActivity() { viewModel.showReaderInfoView = false } - // Reader info ReaderInfoView( - chapterTitle = stats?.run { first.chapter.title } ?: "", - chapterCurrentNumber = stats?.run { first.index + 1 } ?: 0, - chapterPercentageProgress = percetage, + chapterTitle = viewModel.readingPosStats?.run { first.chapter.title } ?: "", + chapterCurrentNumber = viewModel.readingPosStats?.run { first.index + 1 } ?: 0, + chapterPercentageProgress = percentage, chaptersTotalSize = viewModel.orderedChapters.size, - textFont = textFont, - textSize = textSize, + textFont = viewModel.textFont, + textSize = viewModel.textSize, visible = viewModel.showReaderInfoView, onTextFontChanged = { appPreferences.READER_FONT_FAMILY.value = it }, - onTextSizeChanged = { appPreferences.READER_FONT_SIZE.value = it } + onTextSizeChanged = { appPreferences.READER_FONT_SIZE.value = it }, + liveTranslationSettingData = viewModel.liveTranslationSettingData ) } } @@ -206,7 +221,7 @@ class ReaderActivity : BaseActivity() { } } - private fun loadInitialChapter(): Boolean { + private fun loadRestartedInitialChapter(chapterLastState: ReaderViewModel.ChapterState): Boolean { viewModel.readerState = ReaderViewModel.ReaderState.INITIAL_LOAD viewAdapter.listView.clear() @@ -214,8 +229,9 @@ class ReaderActivity : BaseActivity() { val insertAll = { items: Collection -> viewAdapter.listView.addAll(items) } val remove = { item: ReaderItem -> viewAdapter.listView.remove(item) } - val index = - viewModel.orderedChapters.indexOfFirst { it.url == viewModel.currentChapter.url } + val index = viewModel.orderedChapters.indexOfFirst { + it.url == viewModel.currentChapter.url + } if (index == -1) { MaterialDialog(this).show { title(text = getString(R.string.invalid_chapter)) @@ -238,16 +254,63 @@ class ReaderActivity : BaseActivity() { remove, maintainPosition = maintainStartPosition ) { - calculateInitialChapterPosition() + setInitialChapterPosition( + position = chapterLastState.position, + offset = chapterLastState.offset + ) viewBind.listView.isEnabled = true } } - private fun calculateInitialChapterPosition() = lifecycleScope.launch(Dispatchers.Main) { - val (index: Int, offset: Int) = viewModel.getChapterInitialPosition() ?: return@launch + private fun loadInitialChapter(): Boolean { + viewModel.readerState = ReaderViewModel.ReaderState.INITIAL_LOAD + viewAdapter.listView.clear() + + val insert = { item: ReaderItem -> viewAdapter.listView.add(item) } + val insertAll = { items: Collection -> viewAdapter.listView.addAll(items) } + val remove = { item: ReaderItem -> viewAdapter.listView.remove(item) } + + val index = viewModel.orderedChapters.indexOfFirst { + it.url == viewModel.currentChapter.url + } + if (index == -1) { + MaterialDialog(this).show { + title(text = getString(R.string.invalid_chapter)) + cornerRadius(16f) + } + return false + } + + val maintainStartPosition = { fn: () -> Unit -> + fn() + // This is the position of the item TITLE at initialization + viewBind.listView.setSelection(2) + } + + viewBind.listView.isEnabled = false + return addChapter( + index, + insert, + insertAll, + remove, + maintainPosition = maintainStartPosition + ) { + lifecycleScope.launch(Dispatchers.Main) { + val (position: Int, offset: Int) = viewModel.getChapterInitialPosition() + ?: return@launch + setInitialChapterPosition(position = position, offset = offset) + } + viewBind.listView.isEnabled = true + } + } - // index + 1 because it doesn't take into account the first padding view - viewBind.listView.setSelectionFromTop(index + 1, offset) + private fun setInitialChapterPosition(position: Int, offset: Int) { + val index = viewAdapter.listView.list.indexOfFirst { + it is ReaderItem.Position && it.pos == position + } + if (index != -1) { + viewBind.listView.setSelectionFromTop(index, offset) + } viewModel.readerState = ReaderViewModel.ReaderState.IDLE fadeInText() viewBind.listView.doOnNextLayout { updateReadingState() } @@ -271,8 +334,11 @@ class ReaderActivity : BaseActivity() { val item = viewAdapter.listView.getItem(firstVisibleItem) if (item is ReaderItem.Position) { val offset = viewBind.listView.run { getChildAt(0).top - paddingTop } - viewModel.currentChapter = - ChapterState(url = item.chapterUrl, position = item.pos, offset = offset) + viewModel.currentChapter = ReaderViewModel.ChapterState( + url = item.chapterUrl, + position = item.pos, + offset = offset + ) } } @@ -313,10 +379,17 @@ class ReaderActivity : BaseActivity() { } var list_index = 0 - val insert = - { item: ReaderItem -> viewAdapter.listView.insert(item, list_index); list_index += 1 } + val insert = { item: ReaderItem -> + viewAdapter.listView.insert(item, list_index); list_index += 1 + } val insertAll = { items: Collection -> items.forEach { insert(it) } } - val remove = { item: ReaderItem -> viewAdapter.listView.remove(item); list_index -= 1 } + val remove = { item: ReaderItem -> + val pos = viewAdapter.listView.getPosition(item) + if (pos != -1) { + viewAdapter.listView.remove(item); + list_index -= 1 + } + } val maintainLastVisiblePosition = { fn: () -> Unit -> val oldSize = viewAdapter.listView.count @@ -351,22 +424,68 @@ class ReaderActivity : BaseActivity() { maintainPosition: (() -> Unit) -> Unit = { it() }, onCompletion: (() -> Unit) ): Boolean { - val chapter = viewModel.orderedChapters[index] - val itemProgressBar = ReaderItem.PROGRESSBAR(chapter.url) - maintainPosition { - insert(ReaderItem.DIVIDER(chapter.url)) - insert(ReaderItem.TITLE(chapter.url, 0, chapter.title)) - insert(itemProgressBar) - } - lifecycleScope.launch(Dispatchers.Default) { + + val chapter = viewModel.orderedChapters[index] + val itemProgressBar = ReaderItem.PROGRESSBAR(chapter.url) + val itemTitle = ReaderItem.TITLE(chapter.url, 0, chapter.title).copy( + textTranslated = viewModel.translator?.translate(chapter.title)?.await() + ?: chapter.title + ) + withContext(Dispatchers.Main) { + maintainPosition { + insert(ReaderItem.DIVIDER(chapter.url)) + insert(itemTitle) + insert(itemProgressBar) + } + } + when (val res = viewModel.fetchChapterBody(chapter.url)) { is Response.Success -> { - val items = textToItemsConverter(chapter.url, res.data) + // Split chapter text into items + val itemsOriginal = textToItemsConverter(chapter.url, res.data) + + val itemTranslationAttribution = viewModel.translator?.let { + ReaderItem.GOOGLE_TRANSLATE_ATTRIBUTION( + chapterUrl = chapter.url + ) + } + + val itemTranslation = viewModel.translator?.let { + ReaderItem.TRANSLATING( + chapterUrl = chapter.url, + sourceLang = it.sourceLocale.displayLanguage, + targetLang = it.targetLocale.displayLanguage, + ) + } + + if (itemTranslation != null) { + withContext(Dispatchers.Main) { + maintainPosition { + insert(itemTranslation) + } + } + } + + // Translate if necessary + val items = viewModel.translator?.let { translator -> + itemsOriginal.map { + if (it is ReaderItem.BODY) { + it.copy(textTranslated = translator.translate(it.text).await()) + } else it + } + } ?: itemsOriginal + withContext(Dispatchers.Main) { viewModel.addChapterStats(chapter, items.size, index) maintainPosition { remove(itemProgressBar) + itemTranslation?.let { + remove(it) + } + itemTranslationAttribution?.let { + insert(it) + } insertAll(items) insert(ReaderItem.DIVIDER(chapter.url)) } @@ -388,5 +507,3 @@ class ReaderActivity : BaseActivity() { return true } } - - diff --git a/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderItem.kt b/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderItem.kt index d3665f77..97c889d6 100644 --- a/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderItem.kt +++ b/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderItem.kt @@ -2,36 +2,54 @@ package my.noveldokusha.ui.screens.reader import my.noveldokusha.data.BookTextUtils -sealed class ReaderItem -{ +sealed class ReaderItem { abstract val chapterUrl: String - interface Position - { + interface Position { val pos: Int } - enum class LOCATION - { FIRST, MIDDLE, LAST } + enum class LOCATION { FIRST, MIDDLE, LAST } + + data class GOOGLE_TRANSLATE_ATTRIBUTION( + override val chapterUrl: String, + ) : ReaderItem() data class TITLE( override val chapterUrl: String, override val pos: Int, - val text: String - ) : ReaderItem(), Position + val text: String, + val textTranslated: String? = null + ) : ReaderItem(), Position { + val textToDisplay get() = textTranslated ?: text + } data class BODY( override val chapterUrl: String, override val pos: Int, val text: String, - val location: LOCATION - ) : ReaderItem(), Position - { - val image by lazy { BookTextUtils.ImgEntry.fromXMLString(text) } - val isImage by lazy { image != null } + val location: LOCATION, + val textTranslated: String? = null + ) : ReaderItem(), Position { + val textToDisplay get() = textTranslated ?: text } + data class BODY_IMAGE( + override val chapterUrl: String, + override val pos: Int, + val text: String, + val location: LOCATION, + val image: BookTextUtils.ImgEntry + ) : ReaderItem(), Position + + class TRANSLATING( + override val chapterUrl: String, + val sourceLang: String, + val targetLang: String + ) : ReaderItem() + class PROGRESSBAR(override val chapterUrl: String) : ReaderItem() + class DIVIDER(override val chapterUrl: String) : ReaderItem() class BOOK_END(override val chapterUrl: String) : ReaderItem() class BOOK_START(override val chapterUrl: String) : ReaderItem() diff --git a/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderItemAdapter.kt b/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderItemAdapter.kt index a962fd58..385ba737 100644 --- a/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderItemAdapter.kt +++ b/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderItemAdapter.kt @@ -12,6 +12,7 @@ import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import my.noveldokusha.AppPreferences import my.noveldokusha.R import my.noveldokusha.databinding.* +import my.noveldokusha.ui.screens.reader.tools.FontsLoader import my.noveldokusha.utils.inflater import java.io.File import java.util.ArrayList @@ -23,7 +24,7 @@ class ReaderItemAdapter( val fontsLoader: FontsLoader, val appPreferences: AppPreferences, val onChapterStartVisible: (chapterUrl: String) -> Unit, - val onChapterEndVisible: (chapterUrl: String) -> Unit, + val onChapterEndVisible: (chapterUrl: String) -> Unit ) : ArrayAdapter(ctx, 0, list) { @@ -35,13 +36,14 @@ class ReaderItemAdapter( else -> super.getItem(position - 1)!! } - val topPadding = ReaderItem.PADDING("") - val bottomPadding = ReaderItem.PADDING("") + private val topPadding = ReaderItem.PADDING("") + private val bottomPadding = ReaderItem.PADDING("") - override fun getViewTypeCount(): Int = 9 - override fun getItemViewType(position: Int) = when (val item = getItem(position)) + override fun getViewTypeCount(): Int = 11 + override fun getItemViewType(position: Int) = when (getItem(position)) { - is ReaderItem.BODY -> if (item.image != null) 0 else 1 + is ReaderItem.BODY -> 0 + is ReaderItem.BODY_IMAGE -> 1 is ReaderItem.BOOK_END -> 2 is ReaderItem.BOOK_START -> 2 is ReaderItem.DIVIDER -> 3 @@ -49,6 +51,18 @@ class ReaderItemAdapter( is ReaderItem.PADDING -> 5 is ReaderItem.PROGRESSBAR -> 6 is ReaderItem.TITLE -> 7 + is ReaderItem.TRANSLATING -> 8 + is ReaderItem.GOOGLE_TRANSLATE_ATTRIBUTION -> 9 + } + + private fun viewTranslateAttribution(item: ReaderItem.GOOGLE_TRANSLATE_ATTRIBUTION, convertView: View?, parent: ViewGroup): View + { + val bind = when (convertView) + { + null -> ActivityReaderListItemGoogleTranslateAttributionBinding.inflate(parent.inflater, parent, false).also { it.root.tag = it } + else -> ActivityReaderListItemGoogleTranslateAttributionBinding.bind(convertView) + } + return bind.root } private fun viewBody(item: ReaderItem.BODY, convertView: View?, parent: ViewGroup): View @@ -59,7 +73,7 @@ class ReaderItemAdapter( else -> ActivityReaderListItemBodyBinding.bind(convertView) } - val paragraph = item.text + "\n" + val paragraph = item.textToDisplay + "\n" bind.body.text = paragraph bind.body.textSize = appPreferences.READER_FONT_SIZE.value bind.body.typeface = fontsLoader.getTypeFaceNORMAL(appPreferences.READER_FONT_FAMILY.value) @@ -73,7 +87,7 @@ class ReaderItemAdapter( return bind.root } - private fun viewImage(item: ReaderItem.BODY, convertView: View?, parent: ViewGroup): View + private fun viewImage(item: ReaderItem.BODY_IMAGE, convertView: View?, parent: ViewGroup): View { val bind = when (convertView) { @@ -81,25 +95,23 @@ class ReaderItemAdapter( else -> ActivityReaderListItemImageBinding.bind(convertView) } - val imgEntry = item.image ?: return bind.root - bind.image.updateLayoutParams { - dimensionRatio = "1:${imgEntry.yrel}" + dimensionRatio = "1:${item.image.yrel}" } val imageModel = when { - imgEntry.path.startsWith("http://", ignoreCase = true) -> imgEntry.path - imgEntry.path.startsWith("https://", ignoreCase = true) -> imgEntry.path - else -> File(localBookBaseFolder, imgEntry.path) + item.image.path.startsWith("http://", ignoreCase = true) -> item.image.path + item.image.path.startsWith("https://", ignoreCase = true) -> item.image.path + else -> File(localBookBaseFolder, item.image.path) } - + // Glide uses current imageView size to load the bitmap best optimized for it, but current // size corresponds to the last image (different size) and the view layout only updates to // the new values on next redraw. Execute Glide loading call in the next (parent) layout // update to let it get the correct values. // (Avoids getting "blurry" images) bind.imageContainer.doOnNextLayout { - val s = Glide.with(ctx) + Glide.with(ctx) .load(imageModel) .fitCenter() .error(R.drawable.ic_baseline_error_outline_24) @@ -151,6 +163,21 @@ class ReaderItemAdapter( return bind.root } + private fun viewTranslating(item: ReaderItem.TRANSLATING, convertView: View?, parent: ViewGroup): View + { + val bind = when (convertView) + { + null -> ActivityReaderListItemTranslatingBinding.inflate(parent.inflater, parent, false).also { it.root.tag = it } + else -> ActivityReaderListItemTranslatingBinding.bind(convertView) + } + bind.text.text = context.getString( + R.string.translating_from_lang_a_to_lang_b, + item.sourceLang, + item.targetLang + ) + return bind.root + } + private fun viewDivider(item: ReaderItem.DIVIDER, convertView: View?, parent: ViewGroup): View { val bind = when (convertView) @@ -189,24 +216,23 @@ class ReaderItemAdapter( null -> ActivityReaderListItemTitleBinding.inflate(parent.inflater, parent, false).also { it.root.tag = it } else -> ActivityReaderListItemTitleBinding.bind(convertView) } - bind.title.text = item.text + bind.title.text = item.textToDisplay bind.title.typeface = fontsLoader.getTypeFaceBOLD(appPreferences.READER_FONT_FAMILY.value) return bind.root } override fun getView(position: Int, convertView: View?, parent: ViewGroup): View = when (val item = getItem(position)) { - is ReaderItem.BODY -> when (item.isImage) - { - true -> viewImage(item, convertView, parent) - false -> viewBody(item, convertView, parent) - } + is ReaderItem.GOOGLE_TRANSLATE_ATTRIBUTION -> viewTranslateAttribution(item, convertView, parent) + is ReaderItem.BODY -> viewBody(item, convertView, parent) + is ReaderItem.BODY_IMAGE -> viewImage(item, convertView, parent) is ReaderItem.BOOK_END -> viewBookEnd(item, convertView, parent) is ReaderItem.BOOK_START -> viewBookStart(item, convertView, parent) is ReaderItem.DIVIDER -> viewDivider(item, convertView, parent) is ReaderItem.ERROR -> viewError(item, convertView, parent) is ReaderItem.PADDING -> viewPadding(item, convertView, parent) is ReaderItem.PROGRESSBAR -> viewProgressbar(item, convertView, parent) + is ReaderItem.TRANSLATING -> viewTranslating(item, convertView, parent) is ReaderItem.TITLE -> viewTitle(item, convertView, parent) } } \ No newline at end of file diff --git a/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderSettingsViews.kt b/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderSettingsViews.kt index 2b903163..f47adfd7 100644 --- a/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderSettingsViews.kt +++ b/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderSettingsViews.kt @@ -1,6 +1,5 @@ package my.noveldokusha.ui.screens.reader -import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -9,18 +8,24 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowRightAlt +import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.FontDownload import androidx.compose.material.icons.twotone.FormatSize import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -29,10 +34,16 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension +import my.noveldokusha.R +import my.noveldokusha.tools.TranslationModelState +import my.noveldokusha.ui.screens.reader.tools.FontsLoader +import my.noveldokusha.ui.theme.ColorAccent import my.noveldokusha.ui.theme.InternalTheme -import kotlin.math.roundToInt +import my.noveldokusha.utils.blockInteraction +import my.noveldokusha.utils.ifCase +import my.noveldokusha.utils.mix +import my.noveldokusha.utils.clickableWithUnboundedIndicator -@OptIn(ExperimentalAnimationApi::class) @Composable private fun CurrentBookInfo( chapterTitle: String, @@ -83,10 +94,11 @@ private fun CurrentBookInfo( @Composable private fun Settings( textFont: String, - textSize: Float, - modifier: Modifier = Modifier, onTextFontChanged: (String) -> Unit, + textSize: Float, onTextSizeChanged: (Float) -> Unit, + liveTranslationSettingData: LiveTranslationSettingData, + modifier: Modifier = Modifier, ) { Column( modifier @@ -102,6 +114,195 @@ private fun Settings( textFont, onTextFontChanged ) + Box(Modifier.height(10.dp)) + LiveTranslationSetting( + enable = liveTranslationSettingData.enable.value, + listOfAvailableModels = liveTranslationSettingData.listOfAvailableModels, + source = liveTranslationSettingData.source.value, + target = liveTranslationSettingData.target.value, + onEnable = liveTranslationSettingData.onEnable, + onSourceChange = liveTranslationSettingData.onSourceChange, + onTargetChange = liveTranslationSettingData.onTargetChange, + onDownloadTranslationModel = liveTranslationSettingData.onDownloadTranslationModel + ) + } +} + +data class LiveTranslationSettingData( + val enable: MutableState, + val listOfAvailableModels: SnapshotStateList, + val source: MutableState, + val target: MutableState, + val onEnable: (Boolean) -> Unit, + val onSourceChange: (TranslationModelState?) -> Unit, + val onTargetChange: (TranslationModelState?) -> Unit, + val onDownloadTranslationModel: (language: String) -> Unit, +) + +@Composable +fun LiveTranslationSetting( + enable: Boolean, + listOfAvailableModels: List, + source: TranslationModelState?, + target: TranslationModelState?, + onEnable: (Boolean) -> Unit, + onSourceChange: (TranslationModelState?) -> Unit, + onTargetChange: (TranslationModelState?) -> Unit, + onDownloadTranslationModel: (language: String) -> Unit, +) { + + var modelSelectorExpanded by rememberSaveable { mutableStateOf(false) } + var modelSelectorExpandedForTarget by rememberSaveable { mutableStateOf(false) } + var rowSize by remember { mutableStateOf(Size.Zero) } + Row( + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { layoutCoordinates -> + rowSize = layoutCoordinates.size.toSize() + }, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier + .roundedOutline() + .blockInteraction(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier + .roundedOutline() + .clickable { onEnable(!enable) }, + color = if (enable) MaterialTheme.colors.primary.mix( + ColorAccent, + fraction = 0.8f + ) else MaterialTheme.colors.primary + ) { + Text( + text = "Live translation", + modifier = Modifier.padding(12.dp) + ) + } + AnimatedVisibility(visible = enable) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Box( + modifier = Modifier + .clickableWithUnboundedIndicator { + modelSelectorExpanded = !modelSelectorExpanded + modelSelectorExpandedForTarget = false + } + ) { + Text( + text = source?.locale?.displayLanguage + ?: stringResource(R.string.language_source_empty_text), + modifier = Modifier + .padding(6.dp) + .ifCase(source == null) { alpha(0.5f) }, + ) + } + Icon(Icons.Default.ArrowRightAlt, contentDescription = null) + Box( + modifier = Modifier + .clickableWithUnboundedIndicator { + modelSelectorExpanded = !modelSelectorExpanded + modelSelectorExpandedForTarget = true + } + ) { + Text( + text = target?.locale?.displayLanguage + ?: stringResource(R.string.language_target_empty_text), + modifier = Modifier + .padding(6.dp) + .ifCase(target == null) { alpha(0.5f) }, + ) + } + } + } + } + + DropdownMenu( + expanded = modelSelectorExpanded, + onDismissRequest = { modelSelectorExpanded = false }, + offset = DpOffset(0.dp, 10.dp), + modifier = Modifier + .heightIn(max = 300.dp) + .width(with(LocalDensity.current) { rowSize.width.toDp() }) + ) { + + DropdownMenuItem( + onClick = { + if (modelSelectorExpandedForTarget) onTargetChange(null) + else onSourceChange(null) + } + ) { + Box(Modifier.weight(1f)) { + Text( + text = stringResource(R.string.language_clear_selection), + textAlign = TextAlign.Center, + modifier = Modifier + .padding(4.dp) + .background(MaterialTheme.colors.secondary, CircleShape) + .padding(8.dp) + .align(Alignment.Center) + ) + } + } + + listOfAvailableModels.forEach { item -> + val isAvailable = item.model != null + val isAlreadySelected = + if (modelSelectorExpandedForTarget) item.language == target?.language + else item.language == source?.language + DropdownMenuItem( + onClick = { + if (modelSelectorExpandedForTarget) onTargetChange(item) + else onSourceChange(item) + modelSelectorExpanded = false + }, + enabled = !isAlreadySelected && isAvailable + ) { + Box(Modifier.weight(1f)) { + Text( + text = item.locale.displayLanguage, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(4.dp) + .fillMaxWidth() + .align(Alignment.Center) + ) + if (item.model == null) Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .widthIn(min = 22.dp) + .height(22.dp) + .align(Alignment.CenterEnd) + ) { + when { + item.downloading -> IconButton(onClick = { }, enabled = false) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + color = MaterialTheme.colors.onPrimary + ) + } + else -> IconButton( + onClick = { onDownloadTranslationModel(item.language) }) { + Icon( + Icons.Default.CloudDownload, + contentDescription = null, + tint = if (item.downloadingFailed) Color.Red + else LocalContentColor.current.copy(alpha = LocalContentAlpha.current) + ) + } + } + } + } + } + } + } } } @@ -195,6 +396,18 @@ fun TextFontDropDown( } } +private fun Modifier.roundedOutline(): Modifier = composed { + border( + width = 1.dp, + color = MaterialTheme.colors.onPrimary.copy(alpha = 0.5f), + shape = CircleShape + ) + .background( + color = MaterialTheme.colors.primary, + shape = CircleShape + ) + .clip(CircleShape) +} @Composable private fun RoundedContentLayout( @@ -206,16 +419,7 @@ private fun RoundedContentLayout( modifier = modifier .fillMaxWidth() .height(50.dp) - .border( - width = 1.dp, - color = MaterialTheme.colors.onPrimary.copy(alpha = 0.5f), - shape = CircleShape - ) - .background( - color = MaterialTheme.colors.primary, - shape = CircleShape - ) - .clip(CircleShape) + .roundedOutline() .then(modifier) ) { content(this) @@ -232,6 +436,7 @@ fun ReaderInfoView( textSize: Float, onTextFontChanged: (String) -> Unit, onTextSizeChanged: (Float) -> Unit, + liveTranslationSettingData: LiveTranslationSettingData, visible: Boolean, modifier: Modifier = Modifier ) { @@ -263,13 +468,14 @@ fun ReaderInfoView( bottom.linkTo(parent.bottom) width = Dimension.matchParent } - .padding(bottom = 10.dp) + .padding(bottom = 60.dp) ) { Settings( textFont = textFont, textSize = textSize, onTextFontChanged = onTextFontChanged, onTextSizeChanged = onTextSizeChanged, + liveTranslationSettingData = liveTranslationSettingData, ) } } @@ -291,7 +497,35 @@ private fun ViewsPreview() { textSize = 15f, onTextSizeChanged = {}, onTextFontChanged = {}, - visible = true + visible = true, + liveTranslationSettingData = LiveTranslationSettingData( + enable = remember { mutableStateOf(true) }, + listOfAvailableModels = remember { mutableStateListOf() }, + source = remember { + mutableStateOf( + TranslationModelState( + language = "fr", + model = null, + false, + false + ) + ) + }, + target = remember { + mutableStateOf( + TranslationModelState( + language = "en", + model = null, + false, + false + ) + ) + }, + onTargetChange = {}, + onEnable = {}, + onSourceChange = {}, + onDownloadTranslationModel = {} + ) ) } } \ No newline at end of file diff --git a/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderViewModel.kt b/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderViewModel.kt index c2311308..7b726213 100644 --- a/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderViewModel.kt +++ b/app/src/main/java/my/noveldokusha/ui/screens/reader/ReaderViewModel.kt @@ -1,15 +1,22 @@ package my.noveldokusha.ui.screens.reader +import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.* +import com.google.mlkit.nl.translate.TranslateLanguage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.* import my.noveldokusha.AppPreferences import my.noveldokusha.data.Repository import my.noveldokusha.data.database.tables.Chapter +import my.noveldokusha.tools.TranslationManager +import my.noveldokusha.tools.TranslationModelState +import my.noveldokusha.tools.TranslatorState import my.noveldokusha.ui.BaseViewModel +import my.noveldokusha.ui.screens.reader.tools.ChaptersIsReadRoutine +import my.noveldokusha.ui.screens.reader.tools.saveLastReadPositionState import my.noveldokusha.utils.StateExtra_String import java.io.File import java.util.concurrent.atomic.AtomicBoolean @@ -17,8 +24,7 @@ import javax.inject.Inject import kotlin.collections.ArrayList import kotlin.properties.Delegates -interface ReaderStateBundle -{ +interface ReaderStateBundle { var bookUrl: String var chapterUrl: String } @@ -27,20 +33,89 @@ interface ReaderStateBundle class ReaderViewModel @Inject constructor( private val repository: Repository, private val state: SavedStateHandle, - val appPreferences: AppPreferences -) : BaseViewModel(), ReaderStateBundle -{ - enum class ReaderState - { IDLE, LOADING, INITIAL_LOAD } + val appPreferences: AppPreferences, + private val translationManager: TranslationManager, +) : BaseViewModel(), ReaderStateBundle { + enum class ReaderState { IDLE, LOADING, INITIAL_LOAD } + data class ChapterState(val url: String, val position: Int, val offset: Int) data class ChapterStats(val itemCount: Int, val chapter: Chapter, val index: Int) override var bookUrl by StateExtra_String(state) override var chapterUrl by StateExtra_String(state) - val localBookBaseFolder = File(repository.settings.folderBooks, bookUrl.removePrefix("local://")) + var onTranslatorStateChanged: (() -> Unit)? = null - var currentChapter: ChapterState by Delegates.observable(ChapterState(chapterUrl, 0, 0)) { _, old, new -> + val textFont by appPreferences.READER_FONT_FAMILY.state(viewModelScope) + val textSize by appPreferences.READER_FONT_SIZE.state(viewModelScope) + + var translator: TranslatorState? = null + + val liveTranslationSettingData = LiveTranslationSettingData( + listOfAvailableModels = translationManager.models, + enable = mutableStateOf(appPreferences.GLOBAL_TRANSLATIOR_ENABLED.value), + source = mutableStateOf(getValidTranslatorOrNull(appPreferences.GLOBAL_TRANSLATIOR_PREFERRED_SOURCE.value)), + target = mutableStateOf(getValidTranslatorOrNull(appPreferences.GLOBAL_TRANSLATIOR_PREFERRED_TARGET.value)), + onEnable = ::translatorOnEnable, + onSourceChange = ::translatorOnSourceChange, + onTargetChange = ::translatorOnTargetChange, + onDownloadTranslationModel = translationManager::downloadModel + ) + + private fun getValidTranslatorOrNull(language: String): TranslationModelState? { + if (language.isBlank()) return null + return translationManager.hasModelDownloadedSync(language) + } + + private fun updateTranslatorState() { + val isEnabled = liveTranslationSettingData.enable.value + val source = liveTranslationSettingData.source.value + val target = liveTranslationSettingData.target.value + if (!isEnabled || source == null || target == null) { + if (translator == null) return + translator = null + } else { + val old = translator + if (old != null && old.source == source.language && old.target == target.language) + return + if (source.language == target.language) + return + translator = translationManager.getTranslator( + source = source.language, + target = target.language + ) + onTranslatorStateChanged?.invoke() + } + } + + private fun translatorOnEnable(it: Boolean) { + liveTranslationSettingData.enable.value = it + appPreferences.GLOBAL_TRANSLATIOR_ENABLED.value = it + updateTranslatorState() + } + + private fun translatorOnSourceChange(it: TranslationModelState?) { + liveTranslationSettingData.source.value = it + appPreferences.GLOBAL_TRANSLATIOR_PREFERRED_SOURCE.value = it?.language ?: "" + updateTranslatorState() + } + + private fun translatorOnTargetChange(it: TranslationModelState?) { + liveTranslationSettingData.target.value = it + appPreferences.GLOBAL_TRANSLATIOR_PREFERRED_TARGET.value = it?.language ?: "" + updateTranslatorState() + } + + val localBookBaseFolder = + File(repository.settings.folderBooks, bookUrl.removePrefix("local://")) + + var currentChapter: ChapterState by Delegates.observable( + ChapterState( + chapterUrl, + 0, + 0 + ) + ) { _, old, new -> chapterUrl = new.url if (old.url != new.url) saveLastReadPositionState(repository, bookUrl, new, old) } @@ -48,12 +123,15 @@ class ReaderViewModel @Inject constructor( var showReaderInfoView by mutableStateOf(false) val orderedChapters: List - val readingPosStats = MutableLiveData>() + var readingPosStats by mutableStateOf?>(null) + + init { + updateTranslatorState() - init - { - val chapter = viewModelScope.async(Dispatchers.IO) { repository.bookChapter.get(chapterUrl) } - val bookChapter = viewModelScope.async(Dispatchers.IO) { repository.bookChapter.chapters(bookUrl) } + val chapter = + viewModelScope.async(Dispatchers.IO) { repository.bookChapter.get(chapterUrl) } + val bookChapters = + viewModelScope.async(Dispatchers.IO) { repository.bookChapter.chapters(bookUrl) } // Need to fix this somehow runBlocking { @@ -62,7 +140,7 @@ class ReaderViewModel @Inject constructor( position = chapter.await()?.lastReadPosition ?: 0, offset = chapter.await()?.lastReadOffset ?: 0 ) - this@ReaderViewModel.orderedChapters = bookChapter.await() + this@ReaderViewModel.orderedChapters = bookChapters.await() } } @@ -73,14 +151,17 @@ class ReaderViewModel @Inject constructor( private val chaptersStats = mutableMapOf() private val initialLoadDone = AtomicBoolean(false) - override fun onCleared() - { + override fun onCleared() { saveLastReadPositionState(repository, bookUrl, currentChapter) super.onCleared() } - fun initialLoad(fn: () -> Unit) - { + fun reloadReader() { + readerState = ReaderState.INITIAL_LOAD + items.clear() + } + + fun initialLoad(fn: () -> Unit) { if (initialLoadDone.compareAndSet(false, true)) fn() } @@ -90,23 +171,21 @@ class ReaderViewModel @Inject constructor( fun getPreviousChapterIndex(currentChapterUrl: String) = chaptersStats[currentChapterUrl]!!.index - 1 - fun updateInfoViewTo(chapterUrl: String, itemPos: Int) - { + fun updateInfoViewTo(chapterUrl: String, itemPos: Int) { val chapter = chaptersStats[chapterUrl] ?: return - readingPosStats.postValue(Pair(chapter, itemPos)) + readingPosStats = Pair(chapter, itemPos) } - fun addChapterStats(chapter: Chapter, itemCount: Int, index: Int) - { + fun addChapterStats(chapter: Chapter, itemCount: Int, index: Int) { chaptersStats[chapter.url] = ChapterStats(itemCount, chapter, index) } - suspend fun fetchChapterBody(chapterUrl: String) = repository.bookChapterBody.fetchBody(chapterUrl) + suspend fun fetchChapterBody(chapterUrl: String) = + repository.bookChapterBody.fetchBody(chapterUrl) - suspend fun getChapterInitialPosition(): Pair? - { + suspend fun getChapterInitialPosition(): Pair? { val stats = chaptersStats[currentChapter.url] ?: return null - return getChapterInitialPosition( + return my.noveldokusha.ui.screens.reader.tools.getChapterInitialPosition( repository = repository, bookUrl = bookUrl, chapter = stats.chapter, @@ -114,55 +193,3 @@ class ReaderViewModel @Inject constructor( ) } } - -data class ChapterState(val url: String, val position: Int, val offset: Int) - -private fun saveLastReadPositionState( - repository: Repository, - bookUrl: String, - chapter: ChapterState, - oldChapter: ChapterState? = null -) = CoroutineScope(Dispatchers.IO).launch { - repository.withTransaction { - repository.bookLibrary.get(bookUrl)?.let { - repository.bookLibrary.update(it.copy(lastReadChapter = chapter.url)) - } - - if (oldChapter?.url != null) repository.bookChapter.get(oldChapter.url)?.let { - repository.bookChapter.update(it.copy(lastReadPosition = oldChapter.position, lastReadOffset = oldChapter.offset)) - } - - repository.bookChapter.get(chapter.url)?.let { - repository.bookChapter.update(it.copy(lastReadPosition = chapter.position, lastReadOffset = chapter.offset)) - } - } -} - -suspend fun getChapterInitialPosition( - repository: Repository, - bookUrl: String, - chapter: Chapter, - items: ArrayList -): Pair = coroutineScope { - - val book = async(Dispatchers.IO) { repository.bookLibrary.get(bookUrl) } - val titlePos = async(Dispatchers.Default) { - items.indexOfFirst { it is ReaderItem.TITLE } - } - val position = async(Dispatchers.Default) { - items.indexOfFirst { - it is ReaderItem.Position && it.pos == chapter.lastReadPosition - }.let { index -> - if (index == -1) Pair(titlePos.await(), 0) - else Pair(index, chapter.lastReadOffset) - } - } - - when - { - chapter.url == book.await()?.lastReadChapter -> position.await() - chapter.read -> Pair(titlePos.await(), 0) - else -> position.await() - }.let { Pair(it.first.coerceAtLeast(titlePos.await()), it.second) } -} - diff --git a/app/src/main/java/my/noveldokusha/ui/screens/reader/TextToItemsConverter.kt b/app/src/main/java/my/noveldokusha/ui/screens/reader/TextToItemsConverter.kt deleted file mode 100644 index db14501d..00000000 --- a/app/src/main/java/my/noveldokusha/ui/screens/reader/TextToItemsConverter.kt +++ /dev/null @@ -1,22 +0,0 @@ -package my.noveldokusha.ui.screens.reader - -fun textToItemsConverter(chapterUrl: String, text: String): List -{ - val paragraphs = text - .splitToSequence("\n\n") - .filter { it.isNotBlank() } - .withIndex().iterator() - - return sequence { - for ((index, paragraph) in paragraphs) - { - val item = when - { - index == 0 -> ReaderItem.BODY(chapterUrl, index + 1, paragraph, ReaderItem.LOCATION.FIRST) - !paragraphs.hasNext() -> ReaderItem.BODY(chapterUrl, index + 1, paragraph, ReaderItem.LOCATION.LAST) - else -> ReaderItem.BODY(chapterUrl, index + 1, paragraph, ReaderItem.LOCATION.MIDDLE) - } - yield(item) - } - }.toList() -} \ No newline at end of file diff --git a/app/src/main/java/my/noveldokusha/ui/screens/reader/ChaptersIsReadRoutine.kt b/app/src/main/java/my/noveldokusha/ui/screens/reader/tools/ChaptersIsReadRoutine.kt similarity index 96% rename from app/src/main/java/my/noveldokusha/ui/screens/reader/ChaptersIsReadRoutine.kt rename to app/src/main/java/my/noveldokusha/ui/screens/reader/tools/ChaptersIsReadRoutine.kt index b81c2ef6..30543efb 100644 --- a/app/src/main/java/my/noveldokusha/ui/screens/reader/ChaptersIsReadRoutine.kt +++ b/app/src/main/java/my/noveldokusha/ui/screens/reader/tools/ChaptersIsReadRoutine.kt @@ -1,4 +1,4 @@ -package my.noveldokusha.ui.screens.reader +package my.noveldokusha.ui.screens.reader.tools import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/my/noveldokusha/ui/screens/reader/FontsLoader.kt b/app/src/main/java/my/noveldokusha/ui/screens/reader/tools/FontsLoader.kt similarity index 96% rename from app/src/main/java/my/noveldokusha/ui/screens/reader/FontsLoader.kt rename to app/src/main/java/my/noveldokusha/ui/screens/reader/tools/FontsLoader.kt index 6a4e4586..aa204a5a 100644 --- a/app/src/main/java/my/noveldokusha/ui/screens/reader/FontsLoader.kt +++ b/app/src/main/java/my/noveldokusha/ui/screens/reader/tools/FontsLoader.kt @@ -1,4 +1,4 @@ -package my.noveldokusha.ui.screens.reader +package my.noveldokusha.ui.screens.reader.tools import android.graphics.Typeface import androidx.compose.ui.text.font.FontFamily diff --git a/app/src/main/java/my/noveldokusha/ui/screens/reader/tools/GetChapterInitialPosition.kt b/app/src/main/java/my/noveldokusha/ui/screens/reader/tools/GetChapterInitialPosition.kt new file mode 100644 index 00000000..d194d4dc --- /dev/null +++ b/app/src/main/java/my/noveldokusha/ui/screens/reader/tools/GetChapterInitialPosition.kt @@ -0,0 +1,35 @@ +package my.noveldokusha.ui.screens.reader.tools + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import my.noveldokusha.data.Repository +import my.noveldokusha.data.database.tables.Chapter +import my.noveldokusha.ui.screens.reader.ReaderItem + +suspend fun getChapterInitialPosition( + repository: Repository, + bookUrl: String, + chapter: Chapter, + items: ArrayList +): Pair = coroutineScope { + + val book = async(Dispatchers.IO) { repository.bookLibrary.get(bookUrl) } + val titlePos = async(Dispatchers.Default) { + items.indexOfFirst { it is ReaderItem.TITLE } + } + val position = async(Dispatchers.Default) { + items.indexOfFirst { + it is ReaderItem.Position && it.pos == chapter.lastReadPosition + }.let { index -> + if (index == -1) Pair(titlePos.await(), 0) + else Pair(index, chapter.lastReadOffset) + } + } + + when { + chapter.url == book.await()?.lastReadChapter -> position.await() + chapter.read -> Pair(titlePos.await(), 0) + else -> position.await() + }.let { Pair(it.first.coerceAtLeast(titlePos.await()), it.second) } +} \ No newline at end of file diff --git a/app/src/main/java/my/noveldokusha/ui/screens/reader/tools/SaveLastReadPositionState.kt b/app/src/main/java/my/noveldokusha/ui/screens/reader/tools/SaveLastReadPositionState.kt new file mode 100644 index 00000000..4153e07d --- /dev/null +++ b/app/src/main/java/my/noveldokusha/ui/screens/reader/tools/SaveLastReadPositionState.kt @@ -0,0 +1,28 @@ +package my.noveldokusha.ui.screens.reader.tools + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import my.noveldokusha.data.Repository +import my.noveldokusha.ui.screens.reader.ReaderViewModel + +fun saveLastReadPositionState( + repository: Repository, + bookUrl: String, + chapter: ReaderViewModel.ChapterState, + oldChapter: ReaderViewModel.ChapterState? = null +) = CoroutineScope(Dispatchers.IO).launch { + repository.withTransaction { + repository.bookLibrary.get(bookUrl)?.let { + repository.bookLibrary.update(it.copy(lastReadChapter = chapter.url)) + } + + if (oldChapter?.url != null) repository.bookChapter.get(oldChapter.url)?.let { + repository.bookChapter.update(it.copy(lastReadPosition = oldChapter.position, lastReadOffset = oldChapter.offset)) + } + + repository.bookChapter.get(chapter.url)?.let { + repository.bookChapter.update(it.copy(lastReadPosition = chapter.position, lastReadOffset = chapter.offset)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/my/noveldokusha/ui/screens/reader/tools/TextToItemsConverter.kt b/app/src/main/java/my/noveldokusha/ui/screens/reader/tools/TextToItemsConverter.kt new file mode 100644 index 00000000..899a1294 --- /dev/null +++ b/app/src/main/java/my/noveldokusha/ui/screens/reader/tools/TextToItemsConverter.kt @@ -0,0 +1,65 @@ +package my.noveldokusha.ui.screens.reader + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import my.noveldokusha.data.BookTextUtils + +suspend fun textToItemsConverter( + chapterUrl: String, + text: String +): List = withContext(Dispatchers.Default) { + val paragraphs = text + .splitToSequence("\n\n") + .filter { it.isNotBlank() } + .toList() + + return@withContext paragraphs + .mapIndexed { index, paragraph -> + async { + when (index) { + 0 -> generateITEM( + chapterUrl, + index + 1, + paragraph, + ReaderItem.LOCATION.FIRST + ) + paragraphs.lastIndex -> generateITEM( + chapterUrl, + index + 1, + paragraph, + ReaderItem.LOCATION.LAST + ) + else -> generateITEM( + chapterUrl, + index + 1, + paragraph, + ReaderItem.LOCATION.MIDDLE + ) + } + } + } + .awaitAll() +} + +private fun generateITEM( + chapterUrl: String, + pos: Int, + text: String, + location: ReaderItem.LOCATION +): ReaderItem = when (val imgEntry = BookTextUtils.ImgEntry.fromXMLString(text)) { + null -> ReaderItem.BODY( + chapterUrl = chapterUrl, + pos = pos, + text = text, + location = location + ) + else -> ReaderItem.BODY_IMAGE( + chapterUrl = chapterUrl, + pos = pos, + text = text, + location = location, + image = imgEntry + ) +} diff --git a/app/src/main/java/my/noveldokusha/utils/Modifiers.kt b/app/src/main/java/my/noveldokusha/utils/Modifiers.kt index b4c3b5e2..2f7a33a6 100644 --- a/app/src/main/java/my/noveldokusha/utils/Modifiers.kt +++ b/app/src/main/java/my/noveldokusha/utils/Modifiers.kt @@ -1,8 +1,13 @@ package my.noveldokusha.utils +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material.MaterialTheme +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -38,4 +43,11 @@ fun Modifier.drawTopLine(color: Color = MaterialTheme.colors.onPrimary.copy(alph * Blocks any input from passing this composable down the event tree. * Effectively acts as a surface. */ -fun Modifier.blockInteraction() = this.pointerInput(Unit) {} \ No newline at end of file +fun Modifier.blockInteraction() = this.pointerInput(Unit) {} + + +fun Modifier.clickableWithUnboundedIndicator(onClick: () -> Unit) = composed { clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false), + onClick = onClick +) } \ No newline at end of file diff --git a/app/src/main/java/my/noveldokusha/utils/Notifications.kt b/app/src/main/java/my/noveldokusha/utils/NotificationsExtensions.kt similarity index 100% rename from app/src/main/java/my/noveldokusha/utils/Notifications.kt rename to app/src/main/java/my/noveldokusha/utils/NotificationsExtensions.kt diff --git a/app/src/main/res/drawable/google_translate_attribution_greyscale.png b/app/src/main/res/drawable/google_translate_attribution_greyscale.png new file mode 100644 index 00000000..5f5f1ec4 Binary files /dev/null and b/app/src/main/res/drawable/google_translate_attribution_greyscale.png differ diff --git a/app/src/main/res/layout/activity_reader_list_item_google_translate_attribution.xml b/app/src/main/res/layout/activity_reader_list_item_google_translate_attribution.xml new file mode 100644 index 00000000..e83ea01a --- /dev/null +++ b/app/src/main/res/layout/activity_reader_list_item_google_translate_attribution.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/app/src/main/res/layout/activity_reader_list_item_translating.xml b/app/src/main/res/layout/activity_reader_list_item_translating.xml new file mode 100644 index 00000000..4a1474af --- /dev/null +++ b/app/src/main/res/layout/activity_reader_list_item_translating.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ce55d1b0..0478adbd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -103,4 +103,10 @@ Backup options Save images Search chapter title + Download the desired languages models to translate novels on the fly + Live Translation Models + Translating from %s to %s + Source + Target + Clear selection