diff --git a/app/build.gradle b/app/build.gradle index 64430c7..233c2bc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,7 +4,7 @@ apply plugin: 'kotlin-android-extensions' apply plugin: 'io.fabric' ext { - koin_version = "2.0.0-alpha-3" + koin_version = "2.0.0-alpha-6" } android { @@ -50,15 +50,15 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.0.0' // Google & Android libraries - implementation "androidx.appcompat:appcompat:1.1.0-alpha01" - implementation "com.google.android.material:material:1.1.0-alpha02" + implementation "androidx.appcompat:appcompat:1.1.0-alpha03" + implementation "com.google.android.material:material:1.1.0-alpha05" implementation "androidx.cardview:cardview:1.0.0" - implementation "androidx.constraintlayout:constraintlayout:2.0.0-alpha2" + implementation "androidx.constraintlayout:constraintlayout:2.0.0-alpha3" withPlayServicesImplementation('com.crashlytics.sdk.android:crashlytics:2.9.7@aar') { transitive = true } - withPlayServicesImplementation 'com.google.firebase:firebase-core:16.0.6' - withPlayServicesImplementation 'com.google.firebase:firebase-config:16.1.2' + withPlayServicesImplementation 'com.google.firebase:firebase-core:16.0.8' + withPlayServicesImplementation 'com.google.firebase:firebase-config:16.4.1' // Other libraries implementation "com.squareup.retrofit2:retrofit:2.5.0" @@ -70,9 +70,9 @@ dependencies { implementation "org.koin:koin-androidx-viewmodel:$koin_version" // ViewModel and LiveData - implementation "androidx.lifecycle:lifecycle-extensions:2.1.0-alpha01" - annotationProcessor "androidx.lifecycle:lifecycle-compiler:2.1.0-alpha01" - testImplementation "androidx.arch.core:core-testing:2.0.0" + implementation "androidx.lifecycle:lifecycle-extensions:2.1.0-alpha03" + annotationProcessor "androidx.lifecycle:lifecycle-compiler:2.1.0-alpha03" + testImplementation "androidx.arch.core:core-testing:2.0.1" // Tests testImplementation "junit:junit:4.12" diff --git a/app/src/main/java/com/anthony/deepl/openl/DeepLApplication.kt b/app/src/main/java/com/anthony/deepl/openl/DeepLApplication.kt index 6c59a82..2a7bd7d 100644 --- a/app/src/main/java/com/anthony/deepl/openl/DeepLApplication.kt +++ b/app/src/main/java/com/anthony/deepl/openl/DeepLApplication.kt @@ -4,6 +4,7 @@ import android.app.Application import com.anthony.deepl.openl.di.deeplAppModule import com.anthony.deepl.openl.util.FirebaseManager +import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin import org.koin.core.logger.Level @@ -20,6 +21,7 @@ class DeepLApplication : Application() { setupLogTool() startKoin { logger(Level.ERROR) + androidContext(this@DeepLApplication) modules(listOf(deeplAppModule)) } } diff --git a/app/src/main/java/com/anthony/deepl/openl/di/app_module.kt b/app/src/main/java/com/anthony/deepl/openl/di/app_module.kt index 487f658..46336eb 100644 --- a/app/src/main/java/com/anthony/deepl/openl/di/app_module.kt +++ b/app/src/main/java/com/anthony/deepl/openl/di/app_module.kt @@ -1,7 +1,10 @@ package com.anthony.deepl.openl.di import com.anthony.deepl.openl.backend.DeepLService +import com.anthony.deepl.openl.util.FirebaseManager import com.anthony.deepl.openl.view.translation.TranslationViewModel +import org.koin.android.ext.koin.androidApplication +import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module import retrofit2.Retrofit @@ -9,11 +12,10 @@ import retrofit2.converter.moshi.MoshiConverterFactory val deeplAppModule = module { - // ViewModel for Translation View - viewModel { TranslationViewModel(get()) } + viewModel { TranslationViewModel(androidApplication(), get(), get()) } - // Provide DeepL Data Repository single { createDeepLService() } + single { FirebaseManager(androidContext()) } } diff --git a/app/src/main/java/com/anthony/deepl/openl/model/CompletedTranslation.kt b/app/src/main/java/com/anthony/deepl/openl/model/CompletedTranslation.kt new file mode 100644 index 0000000..b6eb88e --- /dev/null +++ b/app/src/main/java/com/anthony/deepl/openl/model/CompletedTranslation.kt @@ -0,0 +1,3 @@ +package com.anthony.deepl.openl.model + +data class CompletedTranslation(val request: TranslationRequest, val response: TranslationResponse) \ No newline at end of file diff --git a/app/src/main/java/com/anthony/deepl/openl/model/ResultResource.kt b/app/src/main/java/com/anthony/deepl/openl/model/ResultResource.kt new file mode 100644 index 0000000..2f2c813 --- /dev/null +++ b/app/src/main/java/com/anthony/deepl/openl/model/ResultResource.kt @@ -0,0 +1,11 @@ +package com.anthony.deepl.openl.model + +sealed class ResultResource + +data class SuccessResource(val data: T) : ResultResource() + +data class FailureResource(val error: Throwable?, val message: String?) : ResultResource() + +data class LoadingResource(val message: String? = null) : ResultResource() + +data class IdleResource(val message: String? = null) : ResultResource() \ No newline at end of file diff --git a/app/src/main/java/com/anthony/deepl/openl/model/TranslationRequest.kt b/app/src/main/java/com/anthony/deepl/openl/model/TranslationRequest.kt index 8d343ca..35d2182 100644 --- a/app/src/main/java/com/anthony/deepl/openl/model/TranslationRequest.kt +++ b/app/src/main/java/com/anthony/deepl/openl/model/TranslationRequest.kt @@ -7,10 +7,10 @@ import java.text.BreakIterator import java.util.ArrayList data class TranslationRequest( - private val sentence: String, - private val fromLanguage: String, - private val toLanguage: String, - private val userPreferredLanguages: List, + val sentence: String, + val fromLanguage: String, + val toLanguage: String, + val userPreferredLanguages: List, @field:Json(name="jsonrpc") private val jsonRpc: String = "2.0", @field:Json(name="method") private val method: String = "LMT_handle_jobs") { diff --git a/app/src/main/java/com/anthony/deepl/openl/util/DeeplKotlinExtentions.kt b/app/src/main/java/com/anthony/deepl/openl/util/DeeplKotlinExtentions.kt index f2b8321..8e15f30 100644 --- a/app/src/main/java/com/anthony/deepl/openl/util/DeeplKotlinExtentions.kt +++ b/app/src/main/java/com/anthony/deepl/openl/util/DeeplKotlinExtentions.kt @@ -7,16 +7,22 @@ import android.content.ClipboardManager import android.content.Context import android.content.Context.CLIPBOARD_SERVICE import android.content.res.Resources +import android.text.Editable +import android.text.TextWatcher import android.view.View import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.TextView import androidx.fragment.app.Fragment + // DIMENSIONS val Int.pxToDp: Int get() = (this / Resources.getSystem().displayMetrics.density).toInt() val Int.dpToPx: Int get() = (this * Resources.getSystem().displayMetrics.density).toInt() + // CLIPBOARD fun Fragment.getClipboardText(): String? { var text: String? = null @@ -39,6 +45,7 @@ fun Fragment.copyToClipboard(text: String) { } } + // KEYBAORD fun Activity.hideKeyboard() { hideKeyboard(if (currentFocus == null) View(this) else currentFocus) @@ -53,4 +60,31 @@ fun Fragment.hideKeyboard() { fun Context.hideKeyboard(view: View) { val inputMethodManager = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) +} + + +// TEXT VIEWS +fun EditText.onTextChanged(lambda: (String) -> Unit) { + this.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(p0: CharSequence, p1: Int, p2: Int, p3: Int) {} + + override fun onTextChanged(p0: CharSequence, p1: Int, p2: Int, p3: Int) { + lambda.invoke(p0.toString()) + } + + override fun afterTextChanged(editable: Editable) {} + }) +} + + +fun TextView.onTextChanged(lambda: (String) -> Unit) { + this.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(p0: CharSequence, p1: Int, p2: Int, p3: Int) {} + + override fun onTextChanged(p0: CharSequence, p1: Int, p2: Int, p3: Int) { + lambda.invoke(p0.toString()) + } + + override fun afterTextChanged(editable: Editable) {} + }) } \ No newline at end of file diff --git a/app/src/main/java/com/anthony/deepl/openl/view/translation/TranslationActivity.kt b/app/src/main/java/com/anthony/deepl/openl/view/translation/TranslationActivity.kt index 079ab97..38cb2ad 100644 --- a/app/src/main/java/com/anthony/deepl/openl/view/translation/TranslationActivity.kt +++ b/app/src/main/java/com/anthony/deepl/openl/view/translation/TranslationActivity.kt @@ -13,9 +13,9 @@ import androidx.appcompat.widget.Toolbar import com.anthony.deepl.openl.manager.LanguageManager import com.anthony.deepl.openl.R -import com.anthony.deepl.openl.util.FirebaseManager import com.google.android.material.snackbar.Snackbar import kotlinx.android.synthetic.main.activity_main.* +import org.koin.androidx.viewmodel.ext.viewModel import java.util.Locale @@ -27,8 +27,8 @@ class TranslationActivity : AppCompatActivity(), TranslationFragment.OnFragmentI private const val SPEECH_TO_TEXT_REQUEST_CODE = 32 } - private lateinit var mTranslationFragment: TranslationFragment - private lateinit var mFirebaseManager: FirebaseManager + private val viewModel by viewModel() + private lateinit var translationFragment: TranslationFragment override val currentMediaVolume: Int get() { @@ -49,13 +49,9 @@ class TranslationActivity : AppCompatActivity(), TranslationFragment.OnFragmentI if (intent?.action == Intent.ACTION_SEND && intent.type == "text/plain") { val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) if (sharedText != null) { - mTranslationFragment.setToTranslateText(sharedText) + translationFragment.setToTranslateText(sharedText) } } - - // We init and fetch values from Firebase analytics and remote config - mFirebaseManager = FirebaseManager(this) - mFirebaseManager.fetchRemoteConfigValues() } public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -63,8 +59,8 @@ class TranslationActivity : AppCompatActivity(), TranslationFragment.OnFragmentI if (requestCode == SPEECH_TO_TEXT_REQUEST_CODE && resultCode == Activity.RESULT_OK && data != null) { val text = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)[0] - mTranslationFragment.setToTranslateText(text) - logEvent("speech_to_text_success", null) + translationFragment.setToTranslateText(text) + viewModel.logEvent("speech_to_text_success", null) } } @@ -76,22 +72,18 @@ class TranslationActivity : AppCompatActivity(), TranslationFragment.OnFragmentI try { startActivityForResult(intent, SPEECH_TO_TEXT_REQUEST_CODE) - logEvent("speech_to_text_displayed", null) + viewModel.logEvent("speech_to_text_displayed", null) } catch (e: ActivityNotFoundException) { - mTranslationFragment.view?.let { + translationFragment.view?.let { Snackbar.make(it, R.string.speech_to_text_error, Snackbar.LENGTH_SHORT).show() } Timber.e(e) } } - override fun logEvent(event: String, bundle: Bundle?) { - mFirebaseManager.logEvent(event, bundle) - } - private fun initViews() { setSupportActionBar(toolbar as Toolbar) - mTranslationFragment = supportFragmentManager.findFragmentById(R.id.main_fragment) as TranslationFragment + translationFragment = supportFragmentManager.findFragmentById(R.id.main_fragment) as TranslationFragment } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/anthony/deepl/openl/view/translation/TranslationFragment.kt b/app/src/main/java/com/anthony/deepl/openl/view/translation/TranslationFragment.kt index b5a8d73..71d737c 100644 --- a/app/src/main/java/com/anthony/deepl/openl/view/translation/TranslationFragment.kt +++ b/app/src/main/java/com/anthony/deepl/openl/view/translation/TranslationFragment.kt @@ -1,11 +1,10 @@ package com.anthony.deepl.openl.view.translation import android.content.Context +import android.os.Build import android.os.Bundle import android.os.Handler import android.speech.tts.TextToSpeech -import android.text.Editable -import android.text.TextWatcher import android.util.TypedValue import android.view.LayoutInflater import android.view.View @@ -20,14 +19,13 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import com.anthony.deepl.openl.R -import com.anthony.deepl.openl.manager.LanguageManager -import com.anthony.deepl.openl.model.TranslationResponse - -import java.util.ArrayList - import timber.log.Timber +import com.anthony.deepl.openl.manager.LanguageManager import com.anthony.deepl.openl.manager.LanguageManager.AUTO +import com.anthony.deepl.openl.model.FailureResource +import com.anthony.deepl.openl.model.LoadingResource +import com.anthony.deepl.openl.model.SuccessResource import com.anthony.deepl.openl.util.* import com.google.android.material.snackbar.Snackbar import kotlinx.android.synthetic.main.fragment_main.* @@ -35,70 +33,73 @@ import org.koin.androidx.viewmodel.ext.sharedViewModel class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItemSelectedListener { - companion object { - private const val INSTANCE_TRANSLATED_FROM_KEY = "last_translated_from" - private const val INSTANCE_TRANSLATED_TO_KEY = "last_translated_to" - private const val INSTANCE_TRANSLATED_SENTENCE_KEY = "last_translated_sentence" - private const val INSTANCE_DETECTED_LANGUAGE_KEY = "detected_language" - private const val INSTANCE_LAST_ALTERNATIVES_KEY = "last_alternatives" - } - private val viewModel by sharedViewModel() - private var mRetrySnackBar: Snackbar? = null - private var mTranslateFromLanguages: Array = arrayOf() - private var mTranslateToLanguages: Array = arrayOf() - private var mLastTranslatedSentence: String? = null - private var mLastTranslatedFrom: String? = null - private var mLastTranslatedTo: String? = null - private var mLastAlternatives: List? = null - private var mDetectedLanguage: String? = null - private var mTextToSpeechInitialized: Boolean = false - private var lastKnownStatus: TranslationViewModel.Status? = null - - private lateinit var mListener: OnFragmentInteractionListener - private lateinit var mTranslateFromAdapter: ShrinkSpinnerAdapter<*> - private lateinit var mTextToSpeech: TextToSpeech + private var retrySnackBar: Snackbar? = null + private var translateFromLanguages: Array = arrayOf() + private var translateToLanguages: Array = arrayOf() + private var textToSpeechInitialized: Boolean = false + + private lateinit var listener: OnFragmentInteractionListener + private lateinit var translateFromAdapter: ShrinkSpinnerAdapter<*> + private lateinit var textToSpeech: TextToSpeech // region Overridden methods override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - mTextToSpeech = TextToSpeech(context, TextToSpeech.OnInitListener { status -> + textToSpeech = TextToSpeech(context, TextToSpeech.OnInitListener { status -> if (status != TextToSpeech.ERROR) { - mTextToSpeechInitialized = true + textToSpeechInitialized = true updateTextToSpeechVisibility() } }) - mTextToSpeechInitialized = false - - // Restore instance state if needed - savedInstanceState?.let { - mLastTranslatedFrom = it.getString(INSTANCE_TRANSLATED_FROM_KEY, null) - mLastTranslatedTo = it.getString(INSTANCE_TRANSLATED_TO_KEY, null) - mLastTranslatedSentence = it.getString(INSTANCE_TRANSLATED_SENTENCE_KEY, null) - mDetectedLanguage = it.getString(INSTANCE_DETECTED_LANGUAGE_KEY, null) - mLastAlternatives = it.getStringArrayList(INSTANCE_LAST_ALTERNATIVES_KEY) - } + textToSpeechInitialized = false } override fun onDestroy() { super.onDestroy() - mTextToSpeech.stop() - mTextToSpeech.shutdown() + textToSpeech.stop() + textToSpeech.shutdown() } override fun onStart() { super.onStart() - viewModel.liveTranslationResponse.observe(this, Observer { translationResponse -> - translationResponse?.let { - displayTranslationResponse(it) + viewModel.getLiveTranslationResponse().observe(this, Observer { resource -> + if (resource !is FailureResource) { + retrySnackBar?.let { + it.dismiss() + retrySnackBar = null + } } - }) - viewModel.liveStatus.observe(this, Observer { status -> - status?.let { - handleViewModelStatusChange(it) + when (resource) { + is FailureResource -> { + translate_progressbar.visibility = View.GONE + translated_edit_text.text = "" + + if (retrySnackBar == null) { + view?.let { view -> + retrySnackBar = Snackbar.make(view, R.string.snack_bar_retry_label, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.snack_bar_retry_button) { + updateTranslation(true) + viewModel.logEvent("retry_snack_bar_tapped", null) + } + retrySnackBar?.show() + } + viewModel.logEvent("retry_snack_bar_displayed", null) + } + } + is SuccessResource -> { + translated_edit_text.text = resource.data.response.getBestTranslation() + updateAlternatives(resource.data.response.getAlternateTranslations().orEmpty()) + if (translate_from_spinner.selectedItemPosition == 0 && resource.data.response.sourceLanguage != null) { + displayDetectedLanguage(resource.data.response.sourceLanguage!!) + } + } + is LoadingResource -> { + translate_progressbar.visibility = View.VISIBLE + } } }) } @@ -112,15 +113,6 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem } } - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putString(INSTANCE_TRANSLATED_FROM_KEY, mLastTranslatedFrom) - outState.putString(INSTANCE_TRANSLATED_TO_KEY, mLastTranslatedTo) - outState.putString(INSTANCE_TRANSLATED_SENTENCE_KEY, mLastTranslatedSentence) - outState.putString(INSTANCE_DETECTED_LANGUAGE_KEY, mDetectedLanguage) - outState.putStringArrayList(INSTANCE_LAST_ALTERNATIVES_KEY, mLastAlternatives as ArrayList?) - } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_main, container, false) } @@ -128,21 +120,14 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initViews() - - // If state has been restored, we may need to display the last detected language - if (mLastTranslatedFrom != null && mLastTranslatedFrom == LanguageManager.AUTO && - mDetectedLanguage != null && translate_from_spinner.selectedItemPosition == 0) { - displayDetectedLanguage() - } - updateAlternatives(view.context) } override fun onAttach(context: Context) { super.onAttach(context) if (context is OnFragmentInteractionListener) { - mListener = context + listener = context } else { - throw RuntimeException(context.toString() + " must implement OnFragmentInteractionListener") + throw RuntimeException("$context must implement OnFragmentInteractionListener") } } @@ -151,8 +136,8 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem R.id.clear_to_translate_button -> clearTextTapped() R.id.mic_fab_button -> { val safeContext = context ?: return - val translateFrom = mTranslateFromLanguages[translate_from_spinner.selectedItemPosition] - mListener.onSpeechToTextTapped(LanguageManager.getLanguageValue(translateFrom, safeContext)) + val translateFrom = translateFromLanguages[translate_from_spinner.selectedItemPosition] + listener.onSpeechToTextTapped(LanguageManager.getLanguageValue(translateFrom, safeContext)) } R.id.text_to_speech_fab_button -> onTextToSpeechTapped() R.id.paste_fab_button -> pasteTextFromClipboard() @@ -164,17 +149,17 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { when (parent.id) { R.id.translate_from_spinner -> { - if (mLastTranslatedFrom != null && position != 0 && mLastTranslatedFrom == LanguageManager.AUTO) { + if (position != 0) { hideDetectedLanguage() } else { checkTranslateFromLabelVisibility() } updateTranslateToSpinner() - mListener.logEvent("changed_translate_from_language", null) + viewModel.logEvent("changed_translate_from_language", null) } R.id.translate_to_spinner -> { - updateTranslation() - mListener.logEvent("changed_translate_to_language", null) + updateTranslation(false) + viewModel.logEvent("changed_translate_to_language", null) } } } @@ -195,14 +180,14 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem // Spinners setup // Default layouts : android.R.layout.simple_spinner_item, android.R.layout.simple_spinner_dropdown_item - mTranslateFromLanguages = LanguageManager.getLanguagesStringArray(safeContext, null, true) - mTranslateFromAdapter = ShrinkSpinnerAdapter(safeContext, R.layout.item_language_spinner, mTranslateFromLanguages) - mTranslateFromAdapter.setDropDownViewResource(R.layout.item_language_spinner_dropdown) - translate_from_spinner.adapter = mTranslateFromAdapter + translateFromLanguages = LanguageManager.getLanguagesStringArray(safeContext, null, true) + translateFromAdapter = ShrinkSpinnerAdapter(safeContext, R.layout.item_language_spinner, translateFromLanguages) + translateFromAdapter.setDropDownViewResource(R.layout.item_language_spinner_dropdown) + translate_from_spinner.adapter = translateFromAdapter // We select the last used translateTo val lastUsedTranslateFrom = LanguageManager.getLastUsedTranslateFrom(safeContext) - for ((index, language) in mTranslateFromLanguages.withIndex()) { + for ((index, language) in translateFromLanguages.withIndex()) { if (LanguageManager.getLanguageValue(language, safeContext) == lastUsedTranslateFrom) { translate_from_spinner.setSelection(index) break @@ -221,71 +206,59 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem text_to_speech_fab_button.hide() copy_to_clipboard_button.hide() invert_languages_button.hide() - to_translate_edit_text.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { - val toTranslateCount = to_translate_edit_text.text.toString().replace(" ", "").length - if (toTranslateCount > 0) { - clear_to_translate_button.visibility = View.VISIBLE - mic_fab_button.hide() - paste_fab_button.hide() - } else { - clear_to_translate_button.visibility = View.GONE - mic_fab_button.show() - if (getClipboardText() != null) { - paste_fab_button.show() - } - } - if (toTranslateCount > 2) { - updateTranslation() - } else { - translated_edit_text.text = "" - alternatives_label.visibility = View.GONE - alternatives_linear_layout.removeAllViews() - mRetrySnackBar?.let { - it.dismiss() - mRetrySnackBar = null - } + to_translate_edit_text.onTextChanged { s -> + val toTranslateCount = s.replace(" ", "").length + if (toTranslateCount > 0) { + clear_to_translate_button.visibility = View.VISIBLE + mic_fab_button.hide() + paste_fab_button.hide() + } else { + clear_to_translate_button.visibility = View.GONE + mic_fab_button.show() + if (getClipboardText() != null) { + paste_fab_button.show() } } - - override fun afterTextChanged(s: Editable) {} - }) - - translated_edit_text.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { - if (!isAdded) return - if (count > 0) { - copy_to_clipboard_button.show() - translated_edit_text.minLines = resources.getInteger(R.integer.min_lines_with_buttons) - } else { - copy_to_clipboard_button.hide() - translated_edit_text.minLines = resources.getInteger(R.integer.min_lines) + if (toTranslateCount > 2) { + updateTranslation(false) + } else { + translated_edit_text.text = "" + alternatives_label.visibility = View.GONE + alternatives_linear_layout.removeAllViews() + retrySnackBar?.let { + it.dismiss() + retrySnackBar = null } - updateTextToSpeechVisibility() } + } - override fun afterTextChanged(s: Editable) {} - }) + translated_edit_text.onTextChanged { s -> + if (!isAdded) return@onTextChanged + if (s.isNotEmpty()) { + copy_to_clipboard_button.show() + translated_edit_text.minLines = resources.getInteger(R.integer.min_lines_with_buttons) + } else { + copy_to_clipboard_button.hide() + translated_edit_text.minLines = resources.getInteger(R.integer.min_lines) + } + updateTextToSpeechVisibility() + } } private fun updateTranslateToSpinner() { val safeContext = context ?: return // We update the translateTo spinner based on translateFrom selected language - val selectedLanguage = when (mDetectedLanguage) { + val selectedLanguage = when (viewModel.detectedLanguage) { null -> LanguageManager.getLanguageValue(translate_from_spinner.selectedItem.toString(), safeContext) - else -> mDetectedLanguage + else -> viewModel.detectedLanguage } - mTranslateToLanguages = LanguageManager.getLanguagesStringArray(safeContext, selectedLanguage, false) - val translateToAdapter = ShrinkSpinnerAdapter(safeContext, R.layout.item_language_spinner, mTranslateToLanguages) + translateToLanguages = LanguageManager.getLanguagesStringArray(safeContext, selectedLanguage, false) + val translateToAdapter = ShrinkSpinnerAdapter(safeContext, R.layout.item_language_spinner, translateToLanguages) translateToAdapter.setDropDownViewResource(R.layout.item_language_spinner_dropdown) translate_to_spinner.adapter = translateToAdapter // We hide invert button if translateFrom is AUTO but language isn't detected - if (selectedLanguage == AUTO && mDetectedLanguage == null) { + if (selectedLanguage == AUTO && viewModel.detectedLanguage == null) { invert_languages_button.hide() } else { invert_languages_button.show() @@ -293,7 +266,7 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem // We select the last used translateTo val lastUsedTranslateTo = LanguageManager.getLastUsedTranslateTo(safeContext) - for ((index, language) in mTranslateToLanguages.withIndex()) { + for ((index, language) in translateToLanguages.withIndex()) { if (LanguageManager.getLanguageValue(language, safeContext) == lastUsedTranslateTo) { translate_to_spinner.setSelection(index) break @@ -301,121 +274,48 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem } } - private fun displayTranslationResponse(translationResponse: TranslationResponse) { + private fun updateTranslation(retry: Boolean) { val safeContext = context ?: return - translated_edit_text.text = translationResponse.getBestTranslation() - - // Alternative translations - mLastAlternatives = translationResponse.getAlternateTranslations() - updateAlternatives(safeContext) - - // Detected language - if (translate_from_spinner.selectedItemPosition == 0) { - mDetectedLanguage = translationResponse.sourceLanguage - displayDetectedLanguage() - } - - // Reporting - val params = Bundle() - params.putString("translate_from", mLastTranslatedFrom) - params.putString("translate_to", mLastTranslatedTo) - mListener.logEvent("translation", params) - } - - private fun handleViewModelStatusChange(status: TranslationViewModel.Status) { - lastKnownStatus = status - if (status != TranslationViewModel.Status.ERROR) { - mRetrySnackBar?.let { - it.dismiss() - mRetrySnackBar = null - } - translate_progressbar.visibility = if (status == TranslationViewModel.Status.IDLE) View.GONE else View.VISIBLE - - } else { - translate_progressbar.visibility = View.GONE - translated_edit_text.text = "" - - if (mRetrySnackBar == null) { - view?.let { view -> - mRetrySnackBar = Snackbar.make(view, R.string.snack_bar_retry_label, Snackbar.LENGTH_INDEFINITE) - .setAction(R.string.snack_bar_retry_button) { - updateTranslation() - mListener.logEvent("retry_snack_bar_tapped", null) - } - mRetrySnackBar?.show() - } - mListener.logEvent("retry_snack_bar_displayed", null) - } - } - } - private fun updateTranslation() { - // If a translation is in progress, we return directly - if (lastKnownStatus?.equals(TranslationViewModel.Status.LOADING) == true || - to_translate_edit_text.text.toString().replace(" ", "").length <= 2 || + // If text to translate is incorrect or languages not selected, we stop + if (to_translate_edit_text.text.toString().replace(" ", "").length <= 2 || translate_from_spinner.selectedItemPosition == -1 || translate_to_spinner.selectedItemPosition == -1) { return } - - // We check if fields have changed since last translation - val safeContext = context ?: return val toTranslate = to_translate_edit_text.text.toString() - var translateFrom = mTranslateFromLanguages[translate_from_spinner.selectedItemPosition] - var translateTo = mTranslateToLanguages[translate_to_spinner.selectedItemPosition] + var translateFrom = translateFromLanguages[translate_from_spinner.selectedItemPosition] + var translateTo = translateToLanguages[translate_to_spinner.selectedItemPosition] translateFrom = LanguageManager.getLanguageValue(translateFrom, safeContext) translateTo = LanguageManager.getLanguageValue(translateTo, safeContext) - if (toTranslate == mLastTranslatedSentence && - translateFrom == mLastTranslatedFrom && - translateTo == mLastTranslatedTo && - lastKnownStatus?.equals(TranslationViewModel.Status.ERROR) == false) { - return - } - - // If languages have changed, we save it to preferences - if (translateFrom != mLastTranslatedFrom) { - LanguageManager.saveLastUsedTranslateFrom(safeContext, translateFrom) - } - if (translateTo != mLastTranslatedTo) { - LanguageManager.saveLastUsedTranslateTo(safeContext, translateTo) - } - - // If fields have changed, we launch a new translation - mLastTranslatedSentence = toTranslate - mLastTranslatedFrom = translateFrom - mLastTranslatedTo = translateTo - val preferredLanguages = ArrayList() - preferredLanguages.add(LanguageManager.getLastUsedTranslateFrom(safeContext)) - preferredLanguages.add(LanguageManager.getLastUsedTranslateTo(safeContext)) - - viewModel.translate(toTranslate, translateFrom, translateTo, preferredLanguages) + viewModel.requestTranslation(toTranslate, translateFrom, translateTo, retry) } - private fun displayDetectedLanguage() { + private fun displayDetectedLanguage(detectedLanguageValue: String) { val safeContext = context ?: return updateTranslateToSpinner() - var detectedLanguage = LanguageManager.getLanguageString(mDetectedLanguage, safeContext) - detectedLanguage = detectedLanguage + " " + getString(R.string.detected_language_label) + val detectedLanguage = LanguageManager.getLanguageString(detectedLanguageValue, safeContext) + " " + getString(R.string.detected_language_label) (translate_from_spinner.selectedView as TextView?)?.text = detectedLanguage - mTranslateFromAdapter.setDetectedLanguage(detectedLanguage) + translateFromAdapter.setDetectedLanguage(detectedLanguage) Handler().postDelayed({ checkTranslateFromLabelVisibility() }, 50) } private fun hideDetectedLanguage() { - mDetectedLanguage = null - mTranslateFromAdapter.clearDetectedLanguage() + viewModel.detectedLanguage = null + translateFromAdapter.clearDetectedLanguage() if (translate_from_spinner.selectedItemPosition == 0) { - (translate_from_spinner.selectedView as TextView).text = mTranslateFromLanguages[0] + (translate_from_spinner.selectedView as TextView).text = translateFromLanguages[0] } Handler().postDelayed({ checkTranslateFromLabelVisibility() }, 50) } - private fun updateAlternatives(context: Context) { + private fun updateAlternatives(alternatives: List) { + if (!isAdded) return alternatives_linear_layout.removeAllViews() - alternatives_label.visibility = if (mLastAlternatives?.isNotEmpty() == true) View.VISIBLE else View.GONE - mLastAlternatives?.let { + alternatives_label.visibility = if (alternatives.isNotEmpty()) View.VISIBLE else View.GONE + alternatives.let { val margin4dp = 4.dpToPx - val textViewColor = ContextCompat.getColor(context, R.color.textBlackColor) + val textViewColor = ContextCompat.getColor(requireContext(), R.color.textBlackColor) val textViewParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) textViewParams.setMargins(0, margin4dp, 0, margin4dp) it.forEach { alternative -> @@ -431,16 +331,16 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem } private fun clearTextTapped() { - mLastTranslatedSentence = "" + viewModel.clearLastResponse() to_translate_edit_text.setText("") translated_edit_text.text = "" alternatives_label.visibility = View.GONE alternatives_linear_layout.removeAllViews() - if (mDetectedLanguage != null) { + if (viewModel.detectedLanguage != null) { hideDetectedLanguage() updateTranslateToSpinner() } - mListener.logEvent("clear_text", null) + viewModel.logEvent("clear_text", null) } private fun copyTranslatedTextToClipboard() { @@ -450,14 +350,14 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem Snackbar.make(clear_to_translate_button, R.string.copied_to_clipboard_text, Snackbar.LENGTH_SHORT).show() - mListener.logEvent("copy_to_clipboard", null) + viewModel.logEvent("copy_to_clipboard", null) } private fun pasteTextFromClipboard() { val clipboardText = getClipboardText() if (clipboardText != null) { setToTranslateText(clipboardText) - mListener.logEvent("paste_from_clipboard", null) + viewModel.logEvent("paste_from_clipboard", null) } else { paste_fab_button.hide() Timber.w("Clipboard is null or primary clip is empty") @@ -466,14 +366,14 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem private fun invertLanguages() { val safeContext = context ?: return - val oldTranslateFrom = when (mDetectedLanguage) { - null -> LanguageManager.getLanguageValue(mTranslateFromLanguages[translate_from_spinner.selectedItemPosition], safeContext) - else -> mDetectedLanguage ?: "" + val oldTranslateFrom = when (viewModel.detectedLanguage) { + null -> LanguageManager.getLanguageValue(translateFromLanguages[translate_from_spinner.selectedItemPosition], safeContext) + else -> viewModel.detectedLanguage ?: "" } - val oldTranslateTo = mTranslateToLanguages[translate_to_spinner.selectedItemPosition] + val oldTranslateTo = translateToLanguages[translate_to_spinner.selectedItemPosition] LanguageManager.saveLastUsedTranslateTo(safeContext, oldTranslateFrom) - for ((index, language) in mTranslateFromLanguages.withIndex()) { + for ((index, language) in translateFromLanguages.withIndex()) { if (language == oldTranslateTo) { translate_from_spinner.setSelection(index) val translateFromValue = LanguageManager.getLanguageValue(language, safeContext) @@ -489,7 +389,7 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem .withEndAction { invert_languages_button.rotation = 0f } .startDelay = 75 - mListener.logEvent("invert_languages", null) + viewModel.logEvent("invert_languages", null) } private fun checkTranslateFromLabelVisibility() { @@ -499,9 +399,10 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem } private fun updateTextToSpeechVisibility() { - if (mTextToSpeechInitialized && mLastTranslatedTo != null && !translated_edit_text.text.toString().isEmpty()) { - val locale = LanguageManager.getLocaleFromLanguageValue(mLastTranslatedTo, mTextToSpeech) - if (mTextToSpeech.isLanguageAvailable(locale) == TextToSpeech.LANG_AVAILABLE) { + val lastTranslatedTo = viewModel.getLastTranslation()?.request?.toLanguage + if (textToSpeechInitialized && lastTranslatedTo != null && !translated_edit_text.text.toString().isEmpty()) { + val locale = LanguageManager.getLocaleFromLanguageValue(lastTranslatedTo, textToSpeech) + if (textToSpeech.isLanguageAvailable(locale) == TextToSpeech.LANG_AVAILABLE) { text_to_speech_fab_button.show() return } @@ -509,17 +410,23 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem text_to_speech_fab_button.hide() } + @Suppress("DEPRECATION") private fun onTextToSpeechTapped() { - if (!mTextToSpeechInitialized || mTextToSpeech.isSpeaking || translated_edit_text.text.toString().isEmpty() || mLastTranslatedTo == null) { + val lastTranslatedTo = viewModel.getLastTranslation()?.request?.toLanguage + if (!textToSpeechInitialized || textToSpeech.isSpeaking || translated_edit_text.text.toString().isEmpty() || lastTranslatedTo == null) { return } - if (mListener.currentMediaVolume > 0) { - mTextToSpeech.language = LanguageManager.getLocaleFromLanguageValue(mLastTranslatedTo, mTextToSpeech) - mTextToSpeech.speak(translated_edit_text.text.toString(), TextToSpeech.QUEUE_FLUSH, null) + if (listener.currentMediaVolume > 0) { + textToSpeech.language = LanguageManager.getLocaleFromLanguageValue(lastTranslatedTo, textToSpeech) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + textToSpeech.speak(translated_edit_text.text.toString(), TextToSpeech.QUEUE_FLUSH, null, null) + } else { + textToSpeech.speak(translated_edit_text.text.toString(), TextToSpeech.QUEUE_FLUSH, null) + } } else { Snackbar.make(clear_to_translate_button, R.string.volume_off_label, Snackbar.LENGTH_SHORT).show() } - mListener.logEvent("text_to_speech", null) + viewModel.logEvent("text_to_speech", null) } // endregion @@ -537,6 +444,5 @@ class TranslationFragment : Fragment(), View.OnClickListener, AdapterView.OnItem interface OnFragmentInteractionListener { val currentMediaVolume: Int fun onSpeechToTextTapped(selectedLocale: String) - fun logEvent(event: String, bundle: Bundle?) } } diff --git a/app/src/main/java/com/anthony/deepl/openl/view/translation/TranslationViewModel.kt b/app/src/main/java/com/anthony/deepl/openl/view/translation/TranslationViewModel.kt index 7567c28..f51402d 100644 --- a/app/src/main/java/com/anthony/deepl/openl/view/translation/TranslationViewModel.kt +++ b/app/src/main/java/com/anthony/deepl/openl/view/translation/TranslationViewModel.kt @@ -1,28 +1,74 @@ package com.anthony.deepl.openl.view.translation +import android.app.Application +import android.os.Bundle +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import com.anthony.deepl.openl.backend.DeepLService -import com.anthony.deepl.openl.model.TranslationRequest -import com.anthony.deepl.openl.model.TranslationResponse +import com.anthony.deepl.openl.manager.LanguageManager +import com.anthony.deepl.openl.model.* +import com.anthony.deepl.openl.util.FirebaseManager +import kotlinx.coroutines.* import retrofit2.Call import retrofit2.Callback import retrofit2.Response import timber.log.Timber +import java.util.ArrayList +import kotlin.coroutines.CoroutineContext -class TranslationViewModel(private val deepLService: DeepLService) : ViewModel() { +class TranslationViewModel(private val app: Application, + private val deepLService: DeepLService, + private val firebaseManager: FirebaseManager) : AndroidViewModel(app), CoroutineScope { - enum class Status { - IDLE, - LOADING, - ERROR + companion object { + private const val REQUEST_DELAY_MS = 600L } - val liveTranslationResponse = MutableLiveData() - val liveStatus = MutableLiveData() + override val coroutineContext: CoroutineContext + get() = Dispatchers.IO - fun translate(toTranslate: String, translateFrom: String, translateTo: String, preferredLanguages: List) { - liveStatus.value = Status.LOADING + private val liveTranslationResponse = MutableLiveData>() + fun getLiveTranslationResponse(): LiveData> = liveTranslationResponse + + var lastRequest: TranslationRequest? = null + var detectedLanguage: String? = null + private var currentRequest: Job? = null + + init { + liveTranslationResponse.postValue(IdleResource()) + firebaseManager.fetchRemoteConfigValues() + } + + fun getLastTranslation(): CompletedTranslation? { + if (liveTranslationResponse.value == null || liveTranslationResponse.value !is SuccessResource) { + return null + } + return (liveTranslationResponse.value as SuccessResource).data + } + + fun requestTranslation(toTranslate: String, translateFrom: String, translateTo: String, retry: Boolean) { + val lastTranslation = getLastTranslation() + + // We check if fields have changed since last translation + if (toTranslate.trim() == lastTranslation?.request?.sentence?.trim() && + translateFrom == lastTranslation.request.fromLanguage && + translateTo == lastTranslation.request.toLanguage && + !retry) { + return + } + + // If languages have changed, we save it to preferences + if (lastTranslation == null || translateFrom != lastTranslation.request.fromLanguage) { + LanguageManager.saveLastUsedTranslateFrom(app, translateFrom) + } + if (lastTranslation == null || translateTo != lastTranslation.request.toLanguage) { + LanguageManager.saveLastUsedTranslateTo(app, translateTo) + } + + val preferredLanguages = ArrayList() + preferredLanguages.add(LanguageManager.getLastUsedTranslateFrom(app)) + preferredLanguages.add(LanguageManager.getLastUsedTranslateTo(app)) val request = TranslationRequest( toTranslate, translateFrom, @@ -30,11 +76,33 @@ class TranslationViewModel(private val deepLService: DeepLService) : ViewModel() preferredLanguages, "2.0", "LMT_handle_jobs") + if (lastRequest != request) { + lastRequest = request + currentRequest?.cancel() + currentRequest = launch { + delay(REQUEST_DELAY_MS) + sendTranslationRequest(request) + } + Timber.d("TRANSLATION VIEW MODEL - ${request.sentence} canceled last job and waiting") + } + } + + fun logEvent(event: String, bundle: Bundle?) { + firebaseManager.logEvent(event, bundle) + } + + fun clearLastResponse() { + liveTranslationResponse.value = IdleResource() + } + + private fun sendTranslationRequest(request: TranslationRequest) { + liveTranslationResponse.postValue(LoadingResource()) + val call = deepLService.translateText(request) call.enqueue(object : Callback { override fun onFailure(call: Call?, t: Throwable?) { Timber.e(t.toString()) - liveStatus.value = Status.ERROR + liveTranslationResponse.value = FailureResource(t, t?.message) } override fun onResponse(call: Call, response: Response) { @@ -43,10 +111,17 @@ class TranslationViewModel(private val deepLService: DeepLService) : ViewModel() onFailure(call, Exception("Translation response body is null")) return } + translationResponse.lineBreakPositions = request.lineBreakPositions - liveTranslationResponse.value = translationResponse - liveStatus.value = Status.IDLE + liveTranslationResponse.value = SuccessResource(CompletedTranslation(request, translationResponse)) + + // Reporting + val params = Bundle() + params.putString("translate_from", request.fromLanguage) + params.putString("translate_to", request.toLanguage) + logEvent("translation", params) } }) + Timber.d("TRANSLATION VIEW MODEL - ${request.sentence} launch request") } } \ No newline at end of file diff --git a/build.gradle b/build.gradle index c3dc7ef..c9a0c6f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = "1.3.11" + ext.kotlin_version = "1.3.21" repositories { google() jcenter() @@ -10,9 +10,9 @@ buildscript { } } dependencies { - classpath 'com.android.tools.build:gradle:3.2.1' + classpath 'com.android.tools.build:gradle:3.3.2' classpath 'com.google.gms:google-services:4.2.0' - classpath 'io.fabric.tools:gradle:1.26.1' + classpath 'io.fabric.tools:gradle:1.28.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3c99581..b1d3822 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Tue Dec 18 15:19:03 CET 2018 +#Thu Mar 28 01:18:48 CET 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip