Bug 1856979 - Translations UI Post-Translate Popup

fenix/125.0
iorgamgabriel 4 months ago committed by mergify[bot]
parent 3b1aa89e19
commit 0505682776

@ -11,6 +11,7 @@ import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
@ -52,6 +53,8 @@ import org.mozilla.fenix.shopping.DefaultShoppingExperienceFeature
import org.mozilla.fenix.shopping.ReviewQualityCheckFeature
import org.mozilla.fenix.shortcut.PwaOnboardingObserver
import org.mozilla.fenix.theme.ThemeManager
import org.mozilla.fenix.translations.TranslationsDialogFragment.Companion.SESSION_ID
import org.mozilla.fenix.translations.TranslationsDialogFragment.Companion.TRANSLATION_IN_PROGRESS
/**
* Fragment used for browsing the web within the main app.
@ -211,6 +214,26 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
owner = viewLifecycleOwner,
view = binding.root,
)
setTranslationFragmentResultListener()
}
private fun setTranslationFragmentResultListener() {
setFragmentResultListener(
TRANSLATION_IN_PROGRESS,
) { _, result ->
result.getString(SESSION_ID)?.let {
if (it == getCurrentTab()?.id) {
FenixSnackbar.make(
view = binding.dynamicSnackbarContainer,
duration = Snackbar.LENGTH_LONG,
isDisplayedWithBrowserToolbar = true,
)
.setText(requireContext().getString(R.string.translation_in_progress_snackbar))
.show()
}
}
}
}
private fun initTranslationsAction(context: Context) {

@ -27,7 +27,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import mozilla.components.concept.engine.translate.Language
import mozilla.components.concept.engine.translate.TranslationError
import org.mozilla.fenix.theme.FirefoxTheme
private const val BOTTOM_SHEET_HANDLE_WIDTH_PERCENT = 0.1f
@ -127,37 +126,29 @@ internal fun TranslationsOptionsAnimation(
}
}
@Composable
@Suppress("LongParameterList")
@Composable
internal fun TranslationsDialog(
translationsDialogState: TranslationsDialogState,
learnMoreUrl: String,
showFirstTimeTranslation: Boolean,
translateFromLanguages: List<Language>?,
translateToLanguages: List<Language>?,
initialFrom: Language? = null,
initialTo: Language? = null,
translationError: TranslationError? = null,
showFirstTime: Boolean = false,
onSettingClicked: () -> Unit,
onLearnMoreClicked: () -> Unit,
onTranslateButtonClick: () -> Unit,
onNotNowButtonClick: () -> Unit,
onPositiveButtonClicked: () -> Unit,
onNegativeButtonClicked: () -> Unit,
onFromSelected: (Language) -> Unit,
onToSelected: (Language) -> Unit,
) {
TranslationsDialogBottomSheet(
translationsDialogState = translationsDialogState,
learnMoreUrl = learnMoreUrl,
showFirstTimeTranslation = showFirstTimeTranslation,
translationError = translationError,
translateFromLanguages = translateFromLanguages,
translateToLanguages = translateToLanguages,
initialFrom = initialFrom,
initialTo = initialTo,
showFirstTimeFlow = showFirstTime,
onSettingClicked = onSettingClicked,
onLearnMoreClicked = onLearnMoreClicked,
onTranslateButtonClicked = onTranslateButtonClick,
onNotNowButtonClicked = onNotNowButtonClick,
onFromSelected = onFromSelected,
onToSelected = onToSelected,
onPositiveButtonClicked = onPositiveButtonClicked,
onNegativeButtonClicked = onNegativeButtonClicked,
onFromDropdownSelected = onFromSelected,
onToDropdownSelected = onToSelected,
)
}

@ -0,0 +1,142 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.translations
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.mapNotNull
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.translate.initialFromLanguage
import mozilla.components.concept.engine.translate.initialToLanguage
import mozilla.components.lib.state.helpers.AbstractBinding
/**
* Helper for observing Translation state from [BrowserStore].
*/
class TranslationsDialogBinding(
browserStore: BrowserStore,
private val translationsDialogStore: TranslationsDialogStore,
private val sessionId: String,
private val getTranslatedPageTitle: (localizedFrom: String?, localizedTo: String?) -> String,
) : AbstractBinding<BrowserState>(browserStore) {
override suspend fun onState(flow: Flow<BrowserState>) {
flow.mapNotNull { state -> state.findTab(sessionId) }
.distinctUntilChangedBy {
it.translationsState
}
.collect { sessionState ->
val translationsState = sessionState.translationsState
val fromSelected =
translationsState.translationEngineState?.initialFromLanguage(
translationsState.supportedLanguages?.fromLanguages,
)
fromSelected?.let {
translationsDialogStore.dispatch(
TranslationsDialogAction.UpdateFromSelectedLanguage(
fromSelected,
),
)
}
val toSelected =
translationsState.translationEngineState?.initialToLanguage(
translationsState.supportedLanguages?.toLanguages,
)
toSelected?.let {
translationsDialogStore.dispatch(
TranslationsDialogAction.UpdateToSelectedLanguage(
toSelected,
),
)
}
if (
toSelected != null && fromSelected != null &&
translationsDialogStore.state.translatedPageTitle == null
) {
translationsDialogStore.dispatch(
TranslationsDialogAction.UpdateTranslatedPageTitle(
getTranslatedPageTitle(
fromSelected.localizedDisplayName,
toSelected.localizedDisplayName,
),
),
)
}
val translateFromLanguages = translationsState.supportedLanguages?.fromLanguages
translateFromLanguages?.let {
translationsDialogStore.dispatch(
TranslationsDialogAction.UpdateTranslateFromLanguages(
translateFromLanguages,
),
)
}
val translateToLanguages = translationsState.supportedLanguages?.toLanguages
translateToLanguages?.let {
translationsDialogStore.dispatch(
TranslationsDialogAction.UpdateTranslateToLanguages(
translateToLanguages,
),
)
}
if (translationsState.isTranslateProcessing) {
updateStoreIfIsTranslateProcessing()
}
if (translationsState.isTranslated && !translationsState.isTranslateProcessing) {
updateStoreIfTranslated()
}
if (translationsState.translationError != null) {
translationsDialogStore.dispatch(
TranslationsDialogAction.UpdateTranslationError(translationsState.translationError),
)
}
}
}
private fun updateStoreIfIsTranslateProcessing() {
translationsDialogStore.dispatch(
TranslationsDialogAction.UpdateTranslationInProgress(
true,
),
)
translationsDialogStore.dispatch(
TranslationsDialogAction.DismissDialog(
dismissDialogState = DismissDialogState.WaitingToBeDismissed,
),
)
}
private fun updateStoreIfTranslated() {
translationsDialogStore.dispatch(
TranslationsDialogAction.UpdateTranslationInProgress(
false,
),
)
translationsDialogStore.dispatch(
TranslationsDialogAction.UpdateTranslated(
true,
),
)
if (translationsDialogStore.state.dismissDialogState == DismissDialogState.WaitingToBeDismissed) {
translationsDialogStore.dispatch(
TranslationsDialogAction.DismissDialog(
dismissDialogState = DismissDialogState.Dismiss,
),
)
}
}
}

@ -67,45 +67,29 @@ private val ICON_SIZE = 24.dp
/**
* Firefox Translations bottom sheet dialog.
*
* @param translationsDialogState The current state of the Translations bottom sheet dialog.
* @param learnMoreUrl The learn more link for translations website.
* @param showFirstTimeTranslation Whether translations first flow should be shown.
* @param translateFromLanguages Translation menu items to be shown in the translate from dropdown.
* @param translateToLanguages Translation menu items are to be shown in the translate to dropdown.
* @param initialFrom The initial selection for the translate from dropdown.
* @param initialTo The initial selection for the translate to dropdown.
* @param translationError The type of translation errors that can occur.
* @param showFirstTimeFlow Whether translations first flow should be shown.
* @param onSettingClicked Invoked when the user clicks on the settings button.
* @param onLearnMoreClicked Invoked when the user clicks on the "Learn More" button.
* @param onTranslateButtonClicked Invoked when the user clicks on the "Translate" button.
* @param onNotNowButtonClicked Invoked when the user clicks on the "Not Now" button.
* @param onFromSelected Invoked when the user selects an item on the from dropdown.
* @param onToSelected Invoked when the user selects an item on the to dropdown.
* @param onPositiveButtonClicked Invoked when the user clicks on the positive button.
* @param onNegativeButtonClicked Invoked when the user clicks on the negative button.
* @param onFromDropdownSelected Invoked when the user selects an item on the from dropdown.
* @param onToDropdownSelected Invoked when the user selects an item on the to dropdown.
*/
@Composable
@Suppress("LongParameterList")
@Composable
fun TranslationsDialogBottomSheet(
translationsDialogState: TranslationsDialogState,
learnMoreUrl: String,
showFirstTimeTranslation: Boolean,
translateFromLanguages: List<Language>?,
translateToLanguages: List<Language>?,
initialFrom: Language? = null,
initialTo: Language? = null,
translationError: TranslationError? = null,
showFirstTimeFlow: Boolean = false,
onSettingClicked: () -> Unit,
onLearnMoreClicked: () -> Unit,
onTranslateButtonClicked: () -> Unit,
onNotNowButtonClicked: () -> Unit,
onFromSelected: (Language) -> Unit,
onToSelected: (Language) -> Unit,
onPositiveButtonClicked: () -> Unit,
onNegativeButtonClicked: () -> Unit,
onFromDropdownSelected: (Language) -> Unit,
onToDropdownSelected: (Language) -> Unit,
) {
var orientation by remember { mutableIntStateOf(Configuration.ORIENTATION_PORTRAIT) }
val configuration = LocalConfiguration.current
LaunchedEffect(configuration) {
snapshotFlow { configuration.orientation }.collect { orientation = it }
}
Column(
modifier = Modifier.padding(16.dp),
) {
@ -116,142 +100,347 @@ fun TranslationsDialogBottomSheet(
)
TranslationsDialogHeader(
showFirstTimeTranslation = showFirstTimeTranslation,
title = if (
translationsDialogState.isTranslated && translationsDialogState.translatedPageTitle != null
) {
translationsDialogState.translatedPageTitle
} else {
getTranslationsDialogTitle(
showFirstTime = showFirstTimeFlow,
)
},
onSettingClicked = onSettingClicked,
)
Spacer(modifier = Modifier.height(8.dp))
if (showFirstTimeTranslation) {
if (showFirstTimeFlow) {
TranslationsDialogInfoMessage(
learnMoreUrl = learnMoreUrl,
onLearnMoreClicked = onLearnMoreClicked,
)
}
translationError?.let {
TranslationErrorWarning(translationError)
}
DialogContentBaseOnTranslationState(
translationsDialogState = translationsDialogState,
learnMoreUrl = learnMoreUrl,
onLearnMoreClicked = onLearnMoreClicked,
onPositiveButtonClicked = onPositiveButtonClicked,
onNegativeButtonClicked = onNegativeButtonClicked,
onFromDropdownSelected = onFromDropdownSelected,
onToDropdownSelected = onToDropdownSelected,
)
}
}
/**
* Dialog content will adapt based on the [TranslationsDialogState].
*
* @param translationsDialogState The current state of the Translations bottom sheet dialog.
* @param learnMoreUrl The learn more link for translations website.
* @param onLearnMoreClicked Invoked when the user clicks on the learn more button.
* @param onPositiveButtonClicked Invoked when the user clicks on the positive button.
* @param onNegativeButtonClicked Invoked when the user clicks on the negative button.
* @param onFromDropdownSelected Invoked when the user selects an item on the from dropdown.
* @param onToDropdownSelected Invoked when the user selects an item on the to dropdown.
*/
@Composable
private fun DialogContentBaseOnTranslationState(
translationsDialogState: TranslationsDialogState,
learnMoreUrl: String,
onLearnMoreClicked: () -> Unit,
onPositiveButtonClicked: () -> Unit,
onNegativeButtonClicked: () -> Unit,
onFromDropdownSelected: (Language) -> Unit,
onToDropdownSelected: (Language) -> Unit,
) {
if (translationsDialogState.error != null) {
DialogContentAnErrorOccurred(
error = translationsDialogState.error,
learnMoreUrl = learnMoreUrl,
onLearnMoreClicked = onLearnMoreClicked,
fromLanguages = translationsDialogState.fromLanguages,
toLanguages = translationsDialogState.toLanguages,
initialFrom = translationsDialogState.initialFrom,
initialTo = translationsDialogState.initialTo,
onFromDropdownSelected = onFromDropdownSelected,
onToDropdownSelected = onToDropdownSelected,
onPositiveButtonClicked = onPositiveButtonClicked,
onNegativeButtonClicked = onNegativeButtonClicked,
)
} else if (translationsDialogState.isTranslated) {
DialogContentTranslated(
translateToLanguages = translationsDialogState.toLanguages,
onFromDropdownSelected = onFromDropdownSelected,
onToDropdownSelected = onToDropdownSelected,
onPositiveButtonClicked = onPositiveButtonClicked,
onNegativeButtonClicked = onNegativeButtonClicked,
positiveButtonType = translationsDialogState.positiveButtonType,
initialTo = translationsDialogState.initialTo,
)
} else {
Spacer(modifier = Modifier.height(14.dp))
if (translationError !is TranslationError.CouldNotLoadLanguagesError &&
translateFromLanguages != null && translateToLanguages != null
) {
when (orientation) {
Configuration.ORIENTATION_LANDSCAPE -> {
TranslationsDialogContentInLandscapeMode(
translateFromLanguages = translateFromLanguages,
translateToLanguages = translateToLanguages,
initialFrom = initialFrom,
initialTo = initialTo,
onFromSelected = onFromSelected,
onToSelected = onToSelected,
)
}
else -> {
TranslationsDialogContentInPortraitMode(
translateFromLanguages = translateFromLanguages,
translateToLanguages = translateToLanguages,
initialFrom = initialFrom,
initialTo = initialTo,
onFromSelected = onFromSelected,
onToSelected = onToSelected,
)
}
}
TranslationsDialogContent(
translateFromLanguages = translationsDialogState.fromLanguages,
translateToLanguages = translationsDialogState.toLanguages,
initialFrom = translationsDialogState.initialFrom,
initialTo = translationsDialogState.initialTo,
onFromDropdownSelected = onFromDropdownSelected,
onToDropdownSelected = onToDropdownSelected,
)
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.height(16.dp))
TranslationsDialogActionButtons(
translationError = translationError,
onTranslateButtonClicked = onTranslateButtonClicked,
onNotNowButtonClicked = onNotNowButtonClicked,
positiveButtonText = stringResource(id = R.string.translations_bottom_sheet_positive_button),
negativeButtonText = stringResource(id = R.string.translations_bottom_sheet_negative_button),
positiveButtonType = translationsDialogState.positiveButtonType,
onNegativeButtonClicked = onNegativeButtonClicked,
onPositiveButtonClicked = onPositiveButtonClicked,
)
}
}
/**
* Dialog content if the web page was translated.
*
* @param translateToLanguages Translation menu items to be shown in the translate to dropdown.
* @param onFromDropdownSelected Invoked when the user selects an item on the from dropdown.
* @param onToDropdownSelected Invoked when the user selects an item on the to dropdown.
* @param onPositiveButtonClicked Invoked when the user clicks on the positive button.
* @param onNegativeButtonClicked Invoked when the user clicks on the negative button.
* @param positiveButtonType Can be enabled,disabled or in progress. If it is null, the button will be disabled.
* @param initialTo Initial "to" language, based on the translation state and page state.
*/
@Composable
private fun TranslationsDialogContentInPortraitMode(
translateFromLanguages: List<Language>,
translateToLanguages: List<Language>,
initialFrom: Language? = null,
private fun DialogContentTranslated(
translateToLanguages: List<Language>?,
onFromDropdownSelected: (Language) -> Unit,
onToDropdownSelected: (Language) -> Unit,
onPositiveButtonClicked: () -> Unit,
onNegativeButtonClicked: () -> Unit,
positiveButtonType: PositiveButtonType? = null,
initialTo: Language? = null,
onFromSelected: (Language) -> Unit,
onToSelected: (Language) -> Unit,
) {
Spacer(modifier = Modifier.height(14.dp))
TranslationsDialogContent(
translateToLanguages = translateToLanguages,
initialTo = initialTo,
onFromDropdownSelected = onFromDropdownSelected,
onToDropdownSelected = onToDropdownSelected,
)
Spacer(modifier = Modifier.height(16.dp))
TranslationsDialogActionButtons(
positiveButtonText = stringResource(id = R.string.translations_bottom_sheet_positive_button),
negativeButtonText = stringResource(id = R.string.translations_bottom_sheet_negative_button_restore),
positiveButtonType = positiveButtonType,
onPositiveButtonClicked = onPositiveButtonClicked,
onNegativeButtonClicked = onNegativeButtonClicked,
)
}
/**
* Dialog content if an [TranslationError] appears during the translation process.
*
* @param error An error that can occur during the translation process.
* @param learnMoreUrl The learn more link for translations website.
* @param onLearnMoreClicked Invoked when the user clicks on the learn more button.
* @param fromLanguages Translation menu items to be shown in the translate from dropdown.
* @param toLanguages Translation menu items to be shown in the translate to dropdown.
* @param initialFrom Initial "from" language, based on the translation state and page state.
* @param initialTo Initial "to" language, based on the translation state and page state.
* @param onFromDropdownSelected Invoked when the user selects an item on the from dropdown.
* @param onToDropdownSelected Invoked when the user selects an item on the to dropdown.
* @param onPositiveButtonClicked Invoked when the user clicks on the positive button.
* @param onNegativeButtonClicked Invoked when the user clicks on the negative button.
*/
@Suppress("LongParameterList")
@Composable
private fun DialogContentAnErrorOccurred(
error: TranslationError,
learnMoreUrl: String,
onLearnMoreClicked: () -> Unit,
fromLanguages: List<Language>?,
toLanguages: List<Language>?,
initialFrom: Language? = null,
initialTo: Language? = null,
onFromDropdownSelected: (Language) -> Unit,
onToDropdownSelected: (Language) -> Unit,
onPositiveButtonClicked: () -> Unit,
onNegativeButtonClicked: () -> Unit,
) {
Column {
TranslationsDropdown(
header = stringResource(id = R.string.translations_bottom_sheet_translate_from),
modifier = Modifier.fillMaxWidth(),
translateLanguages = translateFromLanguages,
initiallySelected = initialFrom,
onLanguageSelection = onFromSelected,
)
TranslationErrorWarning(
error,
learnMoreUrl = learnMoreUrl,
onLearnMoreClicked = onLearnMoreClicked,
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(14.dp))
TranslationsDropdown(
header = stringResource(id = R.string.translations_bottom_sheet_translate_to),
modifier = Modifier.fillMaxWidth(),
translateLanguages = translateToLanguages,
initiallySelected = initialTo,
onLanguageSelection = onToSelected,
if (error !is TranslationError.CouldNotLoadLanguagesError) {
TranslationsDialogContent(
translateFromLanguages = fromLanguages,
translateToLanguages = toLanguages,
initialFrom = initialFrom,
initialTo = initialTo,
onFromDropdownSelected = onFromDropdownSelected,
onToDropdownSelected = onToDropdownSelected,
)
}
val negativeButtonTitle = if (error is TranslationError.LanguageNotSupportedError) {
stringResource(id = R.string.translations_bottom_sheet_negative_button_error)
} else {
stringResource(id = R.string.translations_bottom_sheet_negative_button)
}
val positiveButtonTitle = if (error is TranslationError.CouldNotLoadLanguagesError) {
stringResource(id = R.string.translations_bottom_sheet_positive_button_error)
} else {
stringResource(id = R.string.translations_bottom_sheet_positive_button)
}
val positiveButtonType = if (error is TranslationError.LanguageNotSupportedError) {
PositiveButtonType.Disabled
} else {
PositiveButtonType.Enabled
}
TranslationsDialogActionButtons(
positiveButtonText = positiveButtonTitle,
negativeButtonText = negativeButtonTitle,
positiveButtonType = positiveButtonType,
onNegativeButtonClicked = onNegativeButtonClicked,
onPositiveButtonClicked = onPositiveButtonClicked,
)
}
@Composable
private fun TranslationsDialogContentInLandscapeMode(
translateFromLanguages: List<Language>,
translateToLanguages: List<Language>,
private fun TranslationsDialogContent(
translateFromLanguages: List<Language>? = null,
translateToLanguages: List<Language>? = null,
initialFrom: Language? = null,
initialTo: Language? = null,
onFromDropdownSelected: (Language) -> Unit,
onToDropdownSelected: (Language) -> Unit,
) {
var orientation by remember { mutableIntStateOf(Configuration.ORIENTATION_PORTRAIT) }
val configuration = LocalConfiguration.current
LaunchedEffect(configuration) {
snapshotFlow { configuration.orientation }.collect { orientation = it }
}
when (orientation) {
Configuration.ORIENTATION_LANDSCAPE -> {
TranslationsDialogContentInLandscapeMode(
translateFromLanguages = translateFromLanguages,
translateToLanguages = translateToLanguages,
initialFrom = initialFrom,
initialTo = initialTo,
onFromDropdownSelected = onFromDropdownSelected,
onToDropdownSelected = onToDropdownSelected,
)
}
else -> {
TranslationsDialogContentInPortraitMode(
translateFromLanguages = translateFromLanguages,
translateToLanguages = translateToLanguages,
initialFrom = initialFrom,
initialTo = initialTo,
onFromDropdownSelected = onFromDropdownSelected,
onToDropdownSelected = onToDropdownSelected,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
@Composable
private fun TranslationsDialogContentInPortraitMode(
translateFromLanguages: List<Language>? = null,
translateToLanguages: List<Language>? = null,
initialFrom: Language? = null,
initialTo: Language? = null,
onFromSelected: (Language) -> Unit,
onToSelected: (Language) -> Unit,
onFromDropdownSelected: (Language) -> Unit,
onToDropdownSelected: (Language) -> Unit,
) {
Column {
Row {
translateFromLanguages?.let {
TranslationsDropdown(
header = stringResource(id = R.string.translations_bottom_sheet_translate_from),
modifier = Modifier.weight(1f),
isInLandscapeMode = true,
modifier = Modifier.fillMaxWidth(),
isInLandscapeMode = false,
translateLanguages = translateFromLanguages,
initiallySelected = initialFrom,
onLanguageSelection = onFromSelected,
onLanguageSelection = onFromDropdownSelected,
)
Spacer(modifier = Modifier.width(16.dp))
Spacer(modifier = Modifier.height(16.dp))
}
translateToLanguages?.let {
TranslationsDropdown(
header = stringResource(id = R.string.translations_bottom_sheet_translate_to),
modifier = Modifier.weight(1f),
isInLandscapeMode = true,
translateLanguages = translateToLanguages,
modifier = Modifier.fillMaxWidth(),
isInLandscapeMode = false,
translateLanguages = it,
initiallySelected = initialTo,
onLanguageSelection = onToSelected,
onLanguageSelection = onToDropdownSelected,
)
}
}
}
@Composable
private fun TranslationsDialogHeader(
showFirstTimeTranslation: Boolean,
onSettingClicked: () -> Unit,
private fun TranslationsDialogContentInLandscapeMode(
translateFromLanguages: List<Language>? = null,
translateToLanguages: List<Language>? = null,
initialFrom: Language? = null,
initialTo: Language? = null,
onFromDropdownSelected: (Language) -> Unit,
onToDropdownSelected: (Language) -> Unit,
) {
val title: String = if (showFirstTimeTranslation) {
stringResource(
id = R.string.translations_bottom_sheet_title_first_time,
stringResource(id = R.string.firefox),
)
} else {
stringResource(id = R.string.translations_bottom_sheet_title)
Column {
Row {
translateFromLanguages?.let {
TranslationsDropdown(
header = stringResource(id = R.string.translations_bottom_sheet_translate_from),
modifier = Modifier.weight(1f),
isInLandscapeMode = true,
translateLanguages = translateFromLanguages,
initiallySelected = initialFrom,
onLanguageSelection = onFromDropdownSelected,
)
Spacer(modifier = Modifier.width(16.dp))
}
translateToLanguages?.let {
TranslationsDropdown(
header = stringResource(id = R.string.translations_bottom_sheet_translate_to),
modifier = Modifier.weight(1f),
isInLandscapeMode = true,
translateLanguages = it,
initiallySelected = initialTo,
onLanguageSelection = onToDropdownSelected,
)
}
}
}
}
@Composable
private fun TranslationsDialogHeader(
title: String,
onSettingClicked: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
@ -280,7 +469,11 @@ private fun TranslationsDialogHeader(
}
@Composable
private fun TranslationErrorWarning(translationError: TranslationError) {
private fun TranslationErrorWarning(
translationError: TranslationError,
learnMoreUrl: String,
onLearnMoreClicked: () -> Unit,
) {
val modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth()
@ -303,6 +496,9 @@ private fun TranslationErrorWarning(translationError: TranslationError) {
}
is TranslationError.LanguageNotSupportedError -> {
val learnMoreText =
stringResource(id = R.string.translation_error_language_not_supported_learn_more)
ReviewQualityCheckInfoCard(
title = stringResource(
id = R.string.translation_error_language_not_supported_warning_text,
@ -313,9 +509,9 @@ private fun TranslationErrorWarning(translationError: TranslationError) {
footer = stringResource(
id = R.string.translation_error_language_not_supported_learn_more,
) to LinkTextState(
text = stringResource(id = R.string.translation_error_language_not_supported_learn_more),
url = "https://www.mozilla.org",
onClick = {},
text = learnMoreText,
url = learnMoreUrl,
onClick = { onLearnMoreClicked() },
),
)
}
@ -353,22 +549,13 @@ private fun TranslationsDialogInfoMessage(
}
}
/**
* Creates a dropdown with language selection to use to select languages for translation.
*
* @param header The title of the dropdown.
* @param translateLanguages The language choices the dropdown should provide.
* @param modifier Any modifiers for the component.
* @param isInLandscapeMode If the item should layout for landscape mode.
* @param initiallySelected The language initially selected, if null will show "Choose a language".
* @param onLanguageSelection Callback for the selected language.
*/
@Suppress("LongMethod")
@Composable
private fun TranslationsDropdown(
header: String,
translateLanguages: List<Language>,
modifier: Modifier = Modifier,
isInLandscapeMode: Boolean = false,
isInLandscapeMode: Boolean,
initiallySelected: Language? = null,
onLanguageSelection: (Language) -> Unit,
) {
@ -396,7 +583,8 @@ private fun TranslationsDropdown(
Spacer(modifier = Modifier.height(4.dp))
var initialValue = stringResource(R.string.translations_bottom_sheet_default_dropdown_selection)
var initialValue =
stringResource(R.string.translations_bottom_sheet_default_dropdown_selection)
initiallySelected?.localizedDisplayName?.let {
initialValue = it
}
@ -423,19 +611,21 @@ private fun TranslationsDropdown(
onDismissRequest = {
expanded = false
},
menuItems = getContextMenuItems(
translateLanguages = translateLanguages,
onClickItem = onLanguageSelection,
selectedLanguage = initiallySelected,
onClickItem = {
onLanguageSelection(it)
},
),
modifier = Modifier
.onGloballyPositioned { coordinates ->
contextMenuWidthDp = with(density) {
coordinates.size.width.toDp()
}
}
.requiredSizeIn(maxHeight = 200.dp),
.requiredSizeIn(maxHeight = 200.dp)
.padding(horizontal = if (initiallySelected == null) 36.dp else 4.dp),
offset = if (isInLandscapeMode) {
DpOffset(
-contextMenuWidthDp + ICON_SIZE,
@ -455,8 +645,21 @@ private fun TranslationsDropdown(
}
}
@Composable
private fun getTranslationsDialogTitle(
showFirstTime: Boolean = false,
) = if (showFirstTime) {
stringResource(
id = R.string.translations_bottom_sheet_title_first_time,
stringResource(id = R.string.firefox),
)
} else {
stringResource(id = R.string.translations_bottom_sheet_title)
}
private fun getContextMenuItems(
translateLanguages: List<Language>,
selectedLanguage: Language? = null,
onClickItem: (Language) -> Unit,
): List<MenuItem> {
val menuItems = mutableListOf<MenuItem>()
@ -465,6 +668,7 @@ private fun getContextMenuItems(
menuItems.add(
MenuItem(
title = it,
isChecked = item == selectedLanguage,
onClick = {
onClickItem(item)
},
@ -477,64 +681,52 @@ private fun getContextMenuItems(
@Composable
private fun TranslationsDialogActionButtons(
translationError: TranslationError? = null,
onTranslateButtonClicked: () -> Unit,
onNotNowButtonClicked: () -> Unit,
positiveButtonText: String,
negativeButtonText: String,
positiveButtonType: PositiveButtonType? = null,
onPositiveButtonClicked: () -> Unit,
onNegativeButtonClicked: () -> Unit,
) {
val isTranslationInProgress = remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
val negativeButtonTitle =
if (translationError is TranslationError.LanguageNotSupportedError) {
stringResource(id = R.string.translations_bottom_sheet_negative_button_error)
} else {
stringResource(id = R.string.translations_bottom_sheet_negative_button)
}
TextButton(
text = negativeButtonTitle,
text = negativeButtonText,
modifier = Modifier,
onClick = onNotNowButtonClicked,
onClick = onNegativeButtonClicked,
)
Spacer(modifier = Modifier.width(10.dp))
if (isTranslationInProgress.value) {
DownloadIndicator(
text = stringResource(id = R.string.translations_bottom_sheet_translating_in_progress),
contentDescription = stringResource(
id = R.string.translations_bottom_sheet_translating_in_progress_content_description,
),
icon = painterResource(id = R.drawable.mozac_ic_sync_24),
)
} else {
val positiveButtonTitle =
if (translationError is TranslationError.CouldNotLoadLanguagesError) {
stringResource(id = R.string.translations_bottom_sheet_positive_button_error)
} else {
stringResource(id = R.string.translations_bottom_sheet_positive_button)
}
when (positiveButtonType) {
PositiveButtonType.InProgress -> {
DownloadIndicator(
text = positiveButtonText,
contentDescription = stringResource(
id = R.string.translations_bottom_sheet_translating_in_progress_content_description,
),
icon = painterResource(id = R.drawable.mozac_ic_sync_24),
)
}
if (translationError is TranslationError.LanguageNotSupportedError) {
TertiaryButton(
text = positiveButtonTitle,
enabled = false,
PositiveButtonType.Enabled -> {
PrimaryButton(
text = positiveButtonText,
modifier = Modifier.wrapContentSize(),
) {
isTranslationInProgress.value = true
onTranslateButtonClicked()
onPositiveButtonClicked()
}
} else {
PrimaryButton(
text = positiveButtonTitle,
}
else -> {
TertiaryButton(
text = positiveButtonText,
enabled = false,
modifier = Modifier.wrapContentSize(),
) {
isTranslationInProgress.value = true
onTranslateButtonClicked()
onPositiveButtonClicked()
}
}
}
@ -546,19 +738,19 @@ private fun TranslationsDialogActionButtons(
private fun TranslationsDialogBottomSheetPreview() {
FirefoxTheme {
TranslationsDialogBottomSheet(
translationsDialogState = TranslationsDialogState(
positiveButtonType = PositiveButtonType.Enabled,
toLanguages = getTranslateToLanguageList(),
fromLanguages = getTranslateFromLanguageList(),
),
learnMoreUrl = "",
showFirstTimeTranslation = true,
translationError = TranslationError.LanguageNotSupportedError(null),
translateFromLanguages = getTranslateFromLanguageList(),
translateToLanguages = getTranslateToLanguageList(),
initialFrom = null,
initialTo = null,
showFirstTimeFlow = true,
onSettingClicked = {},
onLearnMoreClicked = {},
onTranslateButtonClicked = {},
onNotNowButtonClicked = {},
onToSelected = {},
onFromSelected = {},
onPositiveButtonClicked = {},
onNegativeButtonClicked = {},
onFromDropdownSelected = {},
onToDropdownSelected = {},
)
}
}

@ -5,11 +5,13 @@
package org.mozilla.fenix.translations
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -18,18 +20,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import mozilla.components.browser.state.action.TranslationsAction
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.translate.TranslationOperation
import mozilla.components.concept.engine.translate.initialFromLanguage
import mozilla.components.concept.engine.translate.initialToLanguage
import mozilla.components.lib.state.ext.observeAsComposableState
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
@ -53,6 +54,9 @@ class TranslationsDialogFragment : BottomSheetDialogFragment() {
private var behavior: BottomSheetBehavior<View>? = null
private val args by navArgs<TranslationsDialogFragmentArgs>()
private val browserStore: BrowserStore by lazy { requireComponents.core.store }
private val translationDialogBinding = ViewBoundFeatureWrapper<TranslationsDialogBinding>()
private lateinit var translationsDialogStore: TranslationsDialogStore
private var isTranslationInProgress: Boolean? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
super.onCreateDialog(savedInstanceState).apply {
@ -70,40 +74,21 @@ class TranslationsDialogFragment : BottomSheetDialogFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?,
): View = ComposeView(requireContext()).apply {
// Signalling need to fetch languages
browserStore.dispatch(
TranslationsAction.OperationRequestedAction(
tabId = args.sessionId,
operation = TranslationOperation.FETCH_SUPPORTED_LANGUAGES,
translationsDialogStore = TranslationsDialogStore(
TranslationsDialogState(),
listOf(
TranslationsDialogMiddleware(
browserStore = browserStore,
sessionId = args.sessionId,
),
),
)
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val translationsState = browserStore.observeAsComposableState {
state ->
state.findTab(args.sessionId)
?.translationsState
}.value
var fromSelected by remember {
mutableStateOf(
translationsState?.translationEngineState
?.initialFromLanguage(translationsState.supportedLanguages?.fromLanguages),
)
}
var toSelected by remember {
mutableStateOf(
translationsState?.translationEngineState
?.initialToLanguage(translationsState.supportedLanguages?.toLanguages),
)
}
FirefoxTheme {
var translationsVisibility by remember {
mutableStateOf(
args.translationsDialogAccessPoint ==
TranslationsDialogAccessPoint.Translations,
args.translationsDialogAccessPoint == TranslationsDialogAccessPoint.Translations,
)
}
@ -139,46 +124,15 @@ class TranslationsDialogFragment : BottomSheetDialogFragment() {
},
) {
val learnMoreUrl = SupportUtils.getSumoURLForTopic(
context,
requireContext(),
SupportUtils.SumoTopic.TRANSLATIONS,
)
TranslationsDialog(
TranslationsDialogContent(
learnMoreUrl = learnMoreUrl,
showFirstTimeTranslation = context.settings().showFirstTimeTranslation,
translateFromLanguages = translationsState?.supportedLanguages?.fromLanguages,
translateToLanguages = translationsState?.supportedLanguages?.toLanguages,
initialFrom = fromSelected,
initialTo = toSelected,
onSettingClicked = {
translationsVisibility = false
},
onLearnMoreClicked = {
(requireActivity() as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = learnMoreUrl,
newTab = true,
from = BrowserDirection.FromTranslationsDialogFragment,
)
},
onTranslateButtonClick = {
fromSelected?.code?.let { fromLanguage ->
toSelected?.code?.let { toLanguage ->
TranslationsAction.TranslateAction(
tabId = args.sessionId,
fromLanguage = fromLanguage,
toLanguage = toLanguage,
options = null,
)
}
}?.let {
browserStore.dispatch(
it,
)
}
},
onNotNowButtonClick = { dismiss() },
onFromSelected = { fromSelected = it },
onToSelected = { toSelected = it },
)
) {
translationsVisibility = false
}
}
}
}
@ -217,4 +171,102 @@ class TranslationsDialogFragment : BottomSheetDialogFragment() {
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
translationDialogBinding.set(
feature = TranslationsDialogBinding(
browserStore = browserStore,
translationsDialogStore = translationsDialogStore,
sessionId = args.sessionId,
getTranslatedPageTitle = { localizedFrom, localizedTo ->
requireContext().getString(
R.string.translations_bottom_sheet_title_translation_completed,
localizedFrom,
localizedTo,
)
},
),
owner = this,
view = view,
)
translationsDialogStore.dispatch(TranslationsDialogAction.InitTranslationsDialog)
}
@Composable
private fun TranslationsDialogContent(learnMoreUrl: String, onSettingClicked: () -> Unit) {
val translationsDialogState =
translationsDialogStore.observeAsComposableState { it }.value
translationsDialogState?.let { state ->
isTranslationInProgress = state.isTranslationInProgress
if (state.dismissDialogState is DismissDialogState.Dismiss) {
dismissDialog()
}
TranslationsDialog(
translationsDialogState = translationsDialogState,
learnMoreUrl = learnMoreUrl,
showFirstTime = requireContext().settings().showFirstTimeTranslation,
onSettingClicked = onSettingClicked,
onLearnMoreClicked = { openBrowserAndLoad(learnMoreUrl) },
onPositiveButtonClicked = {
translationsDialogStore.dispatch(TranslationsDialogAction.TranslateAction)
},
onNegativeButtonClicked = {
if (state.isTranslated) {
translationsDialogStore.dispatch(TranslationsDialogAction.RestoreTranslation)
}
dismiss()
},
onFromSelected = {
translationsDialogStore.dispatch(
TranslationsDialogAction.UpdateFromSelectedLanguage(
it,
),
)
},
onToSelected = {
translationsDialogStore.dispatch(
TranslationsDialogAction.UpdateToSelectedLanguage(
it,
),
)
},
)
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (isTranslationInProgress == true) {
setFragmentResult(
TRANSLATION_IN_PROGRESS,
bundleOf(
SESSION_ID to args.sessionId,
),
)
}
}
private fun openBrowserAndLoad(learnMoreUrl: String) {
(requireActivity() as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = learnMoreUrl,
newTab = true,
from = BrowserDirection.FromTranslationsDialogFragment,
)
}
private fun dismissDialog() {
if (requireContext().settings().showFirstTimeTranslation) {
requireContext().settings().showFirstTimeTranslation = false
}
dismiss()
}
companion object {
const val TRANSLATION_IN_PROGRESS = "translationInProgress"
const val SESSION_ID = "sessionId"
}
}

@ -0,0 +1,62 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.translations
import mozilla.components.browser.state.action.TranslationsAction
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.translate.TranslationOperation
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.MiddlewareContext
/**
* [Middleware] implementation for updating [BrowserStore] based on translation actions.
*/
class TranslationsDialogMiddleware(
private val browserStore: BrowserStore,
private val sessionId: String,
) : Middleware<TranslationsDialogState, TranslationsDialogAction> {
override fun invoke(
context: MiddlewareContext<TranslationsDialogState, TranslationsDialogAction>,
next: (TranslationsDialogAction) -> Unit,
action: TranslationsDialogAction,
) {
when (action) {
is TranslationsDialogAction.FetchSupportedLanguages -> {
browserStore.dispatch(
TranslationsAction.OperationRequestedAction(
tabId = sessionId,
operation = TranslationOperation.FETCH_SUPPORTED_LANGUAGES,
),
)
}
is TranslationsDialogAction.TranslateAction -> {
context.state.initialFrom?.code?.let { fromLanguage ->
context.state.initialTo?.code?.let { toLanguage ->
TranslationsAction.TranslateAction(
tabId = sessionId,
fromLanguage = fromLanguage,
toLanguage = toLanguage,
options = null,
)
}
}?.let {
browserStore.dispatch(
it,
)
}
}
is TranslationsDialogAction.RestoreTranslation -> {
browserStore.dispatch(TranslationsAction.TranslateRestoreAction(sessionId))
}
else -> {
next(action)
}
}
}
}

@ -0,0 +1,285 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.translations
import mozilla.components.browser.state.action.TranslationsAction
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.translate.Language
import mozilla.components.concept.engine.translate.TranslationError
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
/**
* The [TranslationsDialogStore] holds the [TranslationsDialogState] (state tree).
*
* The only way to change the [TranslationsDialogState] inside
* [TranslationsDialogStore] is to dispatch an [Action] on it.
*/
class TranslationsDialogStore(
initialState: TranslationsDialogState,
middlewares: List<Middleware<TranslationsDialogState, TranslationsDialogAction>> = emptyList(),
) : Store<TranslationsDialogState, TranslationsDialogAction>(
initialState,
TranslationsDialogReducer::reduce,
middlewares,
) {
init {
dispatch(TranslationsDialogAction.FetchSupportedLanguages)
}
}
/**
* The current state of the Translations bottom sheet dialog.
*
* @property isTranslated The page is currently translated.
* @property isTranslationInProgress The page is currently attempting a translation.
* @property positiveButtonType Can be enabled,disabled or in progress.
* @property error An error that can occur during the translation process.
* @property dismissDialogState Whether the dialog bottom sheet should be dismissed.
* @property initialFrom Initial "from" language, based on the translation state and page state.
* @property initialTo Initial "to" language, based on the translation state and page state.
* @property fromLanguages Translation menu items to be shown in the translate from dropdown.
* @property toLanguages Translation menu items to be shown in the translate to dropdown.
* @property translatedPageTitle Title of the bottom sheet dialogue if the page was translated.
*/
data class TranslationsDialogState(
var isTranslated: Boolean = false,
val isTranslationInProgress: Boolean = false,
val positiveButtonType: PositiveButtonType? = null,
val error: TranslationError? = null,
val dismissDialogState: DismissDialogState? = null,
val initialFrom: Language? = null,
val initialTo: Language? = null,
val fromLanguages: List<Language>? = null,
val toLanguages: List<Language>? = null,
val translatedPageTitle: String? = null,
) : State
/**
* Action to dispatch through the `TranslationsStore` to modify `TranslationsDialogState` through the reducer.
*/
sealed class TranslationsDialogAction : Action {
/**
* Invoked when the [TranslationsDialogStore] is added to the fragment.
*/
object InitTranslationsDialog : TranslationsDialogAction()
/**
* When FetchSupportedLanguages is dispatched, an [TranslationsAction.OperationRequestedAction]
* will be dispatched to the [BrowserStore]
*/
object FetchSupportedLanguages : TranslationsDialogAction()
/**
* Invoked when the user wants to translate a website.
*/
object TranslateAction : TranslationsDialogAction()
/**
* Invoked when the user wants to restore the website to its original pre-translated content.
*/
object RestoreTranslation : TranslationsDialogAction()
/**
* Invoked when a translation error occurs during the translation process.
*/
data class UpdateTranslationError(
val translationError: TranslationError? = null,
) : TranslationsDialogAction()
/**
* Updates translate from languages list.
*/
data class UpdateTranslateFromLanguages(
val translateFromLanguages: List<Language>,
) : TranslationsDialogAction()
/**
* Updates translate to languages list.
*/
data class UpdateTranslateToLanguages(
val translateToLanguages: List<Language>,
) : TranslationsDialogAction()
/**
* Updates to the current selected language from the "translateFromLanguages" list.
*/
data class UpdateFromSelectedLanguage(
val language: Language,
) : TranslationsDialogAction()
/**
* Updates to the current selected language from the "translateToLanguages" list.
*/
data class UpdateToSelectedLanguage(
val language: Language,
) : TranslationsDialogAction()
/**
* Dismiss the translation dialog fragment.
*/
data class DismissDialog(
val dismissDialogState: DismissDialogState,
) : TranslationsDialogAction()
/**
* Updates the dialog content to translation in progress status.
*/
data class UpdateTranslationInProgress(
val inProgress: Boolean,
) : TranslationsDialogAction()
/**
* Updates the dialog content to translated status.
*/
data class UpdateTranslated(
val isTranslated: Boolean,
) : TranslationsDialogAction()
/**
* Updates the dialog title if the page was translated.
*/
data class UpdateTranslatedPageTitle(val title: String) : TranslationsDialogAction()
}
/**
* Positive button type from the translation bottom sheet.
*/
sealed class PositiveButtonType {
/**
* The translating indicator will appear.
*/
object InProgress : PositiveButtonType()
/**
* The button is in a disabled state.
*/
object Disabled : PositiveButtonType()
/**
* The button is in a enabled state.
*/
object Enabled : PositiveButtonType()
}
/**
* Dismiss translation bottom sheet type.
*/
sealed class DismissDialogState {
/**
* The dialog should be dismissed.
*/
object Dismiss : DismissDialogState()
/**
* This is the step when translation is in progress and the dialog is waiting to be dismissed.
*/
object WaitingToBeDismissed : DismissDialogState()
}
internal object TranslationsDialogReducer {
/**
* Reduces the translations dialog state from the current state and an action performed on it.
*
* @param state The current translations dialog state.
* @param action The action to perform.
* @return The new [TranslationsDialogState].
*/
@Suppress("LongMethod")
fun reduce(
state: TranslationsDialogState,
action: TranslationsDialogAction,
): TranslationsDialogState {
return when (action) {
is TranslationsDialogAction.UpdateFromSelectedLanguage -> {
state.copy(
initialFrom = action.language,
positiveButtonType = if (state.initialTo != null && action.language != state.initialTo) {
PositiveButtonType.Enabled
} else {
PositiveButtonType.Disabled
},
)
}
is TranslationsDialogAction.UpdateToSelectedLanguage -> {
state.copy(
initialTo = action.language,
positiveButtonType = if (state.initialFrom != null && action.language != state.initialFrom) {
PositiveButtonType.Enabled
} else {
PositiveButtonType.Disabled
},
)
}
is TranslationsDialogAction.UpdateTranslateToLanguages -> {
state.copy(toLanguages = action.translateToLanguages)
}
is TranslationsDialogAction.UpdateTranslateFromLanguages -> {
state.copy(fromLanguages = action.translateFromLanguages)
}
is TranslationsDialogAction.DismissDialog -> {
state.copy(dismissDialogState = action.dismissDialogState)
}
is TranslationsDialogAction.UpdateTranslationInProgress -> {
state.copy(
isTranslationInProgress = action.inProgress,
positiveButtonType = if (action.inProgress) {
PositiveButtonType.InProgress
} else {
state.positiveButtonType
},
)
}
is TranslationsDialogAction.InitTranslationsDialog -> {
state.copy(
positiveButtonType = if (state.initialTo == null || state.initialFrom == null) {
PositiveButtonType.Disabled
} else {
state.positiveButtonType
},
)
}
is TranslationsDialogAction.UpdateTranslationError -> {
state.copy(
error = action.translationError,
positiveButtonType = if (
action.translationError is TranslationError.LanguageNotSupportedError
) {
PositiveButtonType.Disabled
} else {
PositiveButtonType.Enabled
},
)
}
is TranslationsDialogAction.UpdateTranslated -> {
state.copy(
isTranslated = action.isTranslated,
positiveButtonType = PositiveButtonType.Disabled,
)
}
is TranslationsDialogAction.UpdateTranslatedPageTitle -> {
state.copy(translatedPageTitle = action.title)
}
is TranslationsDialogAction.TranslateAction,
TranslationsDialogAction.FetchSupportedLanguages,
TranslationsDialogAction.RestoreTranslation,
-> {
// handled by [TranslationsDialogMiddleware]
state
}
}
}
}

@ -2322,6 +2322,10 @@
<!-- Translation request dialog -->
<!-- Title for the translation dialog that allows a user to translate the webpage. -->
<string name="translations_bottom_sheet_title">Translate this page?</string>
<!-- Title for the translation dialog after a translation was completed successfully.
The first parameter is the name of the language that the page was translated from, for example, "French".
The second parameter is the name of the language that the page was translated to, for example, "English". -->
<string name="translations_bottom_sheet_title_translation_completed">Page translated from %1$s to %2$s</string>
<!-- Title for the translation dialog that allows a user to translate the webpage when a user uses the translation feature the first time. The first parameter is the name of the application, for example, "Fenix". -->
<string name="translations_bottom_sheet_title_first_time">Try private translations in %1$s</string>
<!-- Additional information on the translation dialog that appears when a user uses the translation feature the first time. The first parameter is clickable text with a link, for example, "Learn more". -->
@ -2334,6 +2338,8 @@
<string name="translations_bottom_sheet_translate_to">Translate to</string>
<!-- Button text on the translations dialog to dismiss the dialog and return to the browser. -->
<string name="translations_bottom_sheet_negative_button">Not now</string>
<!-- Button text on the translations dialog to restore the translated website back to the original untranslated version. -->
<string name="translations_bottom_sheet_negative_button_restore">Show original</string>
<!-- Button text on the translations dialog when a translation error appears, used to dismiss the dialog and return to the browser. -->
<string name="translations_bottom_sheet_negative_button_error">Done</string>
<!-- Button text on the translations dialog to begin a translation of the website. -->
@ -2354,6 +2360,8 @@
<string name="translation_error_language_not_supported_warning_text">Sorry, we dont support %1$s yet.</string>
<!-- Button text on the warning card when a language is not supported. The link will take the user to a page to a support page about translations. -->
<string name="translation_error_language_not_supported_learn_more">Learn more</string>
<!-- Snackbar title shown if the user closes the Translation Request dialogue and a translation is in progress. -->
<string name="translation_in_progress_snackbar">Translating…</string>
<!-- Translations options dialog -->
<!-- Title of the translation options dialog that allows a user to set their translation options for the site the user is currently on. -->

@ -0,0 +1,308 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.translations
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.browser.state.action.TranslationsAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.translate.DetectedLanguages
import mozilla.components.concept.engine.translate.Language
import mozilla.components.concept.engine.translate.TranslationEngineState
import mozilla.components.concept.engine.translate.TranslationError
import mozilla.components.concept.engine.translate.TranslationOperation
import mozilla.components.concept.engine.translate.TranslationPair
import mozilla.components.concept.engine.translate.TranslationSupport
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.robolectric.testContext
import mozilla.components.support.test.rule.MainCoroutineRule
import mozilla.components.support.test.rule.runTestOnMain
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.spy
import org.mockito.Mockito.verify
import org.mozilla.fenix.R
@RunWith(AndroidJUnit4::class)
class TranslationsDialogBindingTest {
@get:Rule
val coroutineRule = MainCoroutineRule()
lateinit var browserStore: BrowserStore
private lateinit var translationsDialogStore: TranslationsDialogStore
private val tabId = "1"
private val tab = createTab(url = tabId, id = tabId)
@Test
fun `WHEN fromLanguage and toLanguage get updated in the browserStore THEN translations dialog actions dispatched with the update`() =
runTestOnMain {
val englishLanguage = Language("en", "English")
val spanishLanguage = Language("es", "Spanish")
translationsDialogStore = spy(TranslationsDialogStore(TranslationsDialogState()))
browserStore = BrowserStore(
BrowserState(
tabs = listOf(tab),
selectedTabId = tabId,
),
)
val binding = TranslationsDialogBinding(
browserStore = browserStore,
translationsDialogStore = translationsDialogStore,
sessionId = tabId,
getTranslatedPageTitle = { localizedFrom, localizedTo ->
testContext.getString(
R.string.translations_bottom_sheet_title_translation_completed,
localizedFrom,
localizedTo,
)
},
)
binding.start()
val detectedLanguages = DetectedLanguages(
documentLangTag = englishLanguage.code,
supportedDocumentLang = true,
userPreferredLangTag = spanishLanguage.code,
)
val translationEngineState = TranslationEngineState(
detectedLanguages = detectedLanguages,
error = null,
isEngineReady = true,
requestedTranslationPair = TranslationPair(
fromLanguage = englishLanguage.code,
toLanguage = spanishLanguage.code,
),
)
val supportLanguages = TranslationSupport(
fromLanguages = listOf(englishLanguage),
toLanguages = listOf(spanishLanguage),
)
browserStore.dispatch(
TranslationsAction.SetSupportedLanguagesAction(
tabId = tab.id,
supportedLanguages = supportLanguages,
),
).joinBlocking()
browserStore.dispatch(
TranslationsAction.TranslateStateChangeAction(
tabId = tabId,
translationEngineState = translationEngineState,
),
).joinBlocking()
verify(translationsDialogStore).dispatch(
TranslationsDialogAction.UpdateFromSelectedLanguage(
englishLanguage,
),
)
verify(translationsDialogStore).dispatch(
TranslationsDialogAction.UpdateToSelectedLanguage(
spanishLanguage,
),
)
verify(translationsDialogStore).dispatch(
TranslationsDialogAction.UpdateTranslatedPageTitle(
testContext.getString(
R.string.translations_bottom_sheet_title_translation_completed,
englishLanguage.localizedDisplayName,
spanishLanguage.localizedDisplayName,
),
),
)
}
@Test
fun `WHEN translate action is sent to the browserStore THEN update translation dialog store based on operation`() =
runTestOnMain {
val englishLanguage = Language("en", "English")
val spanishLanguage = Language("es", "Spanish")
translationsDialogStore = spy(TranslationsDialogStore(TranslationsDialogState()))
browserStore = BrowserStore(
BrowserState(
tabs = listOf(tab),
selectedTabId = tabId,
),
)
val binding = TranslationsDialogBinding(
browserStore = browserStore,
translationsDialogStore = translationsDialogStore,
sessionId = tabId,
getTranslatedPageTitle = { localizedFrom, localizedTo ->
testContext.getString(
R.string.translations_bottom_sheet_title_translation_completed,
localizedFrom,
localizedTo,
)
},
)
binding.start()
browserStore.dispatch(
TranslationsAction.TranslateAction(
tabId = tabId,
fromLanguage = englishLanguage.code,
toLanguage = spanishLanguage.code,
null,
),
).joinBlocking()
verify(translationsDialogStore).dispatch(
TranslationsDialogAction.UpdateTranslationInProgress(
true,
),
)
verify(translationsDialogStore).dispatch(
TranslationsDialogAction.DismissDialog(
dismissDialogState = DismissDialogState.WaitingToBeDismissed,
),
)
}
@Test
fun `WHEN translate from languages list and translate to languages list are sent to the browserStore THEN update translation dialog store based on operation`() =
runTestOnMain {
translationsDialogStore = spy(TranslationsDialogStore(TranslationsDialogState()))
browserStore = BrowserStore(
BrowserState(
tabs = listOf(tab),
selectedTabId = tabId,
),
)
val binding = TranslationsDialogBinding(
browserStore = browserStore,
translationsDialogStore = translationsDialogStore,
sessionId = tabId,
getTranslatedPageTitle = { localizedFrom, localizedTo ->
testContext.getString(
R.string.translations_bottom_sheet_title_translation_completed,
localizedFrom,
localizedTo,
)
},
)
binding.start()
val toLanguage = Language("de", "German")
val fromLanguage = Language("es", "Spanish")
val supportedLanguages = TranslationSupport(listOf(fromLanguage), listOf(toLanguage))
browserStore.dispatch(
TranslationsAction.SetSupportedLanguagesAction(
tabId = tab.id,
supportedLanguages = supportedLanguages,
),
).joinBlocking()
verify(translationsDialogStore).dispatch(
TranslationsDialogAction.UpdateTranslateFromLanguages(
listOf(fromLanguage),
),
)
verify(translationsDialogStore).dispatch(
TranslationsDialogAction.UpdateTranslateToLanguages(
listOf(toLanguage),
),
)
}
@Test
fun `WHEN translate action success is sent to the browserStore THEN update translation dialog store based on operation`() =
runTestOnMain {
translationsDialogStore =
spy(TranslationsDialogStore(TranslationsDialogState(dismissDialogState = DismissDialogState.WaitingToBeDismissed)))
browserStore = BrowserStore(
BrowserState(
tabs = listOf(tab),
selectedTabId = tabId,
),
)
val binding = TranslationsDialogBinding(
browserStore = browserStore,
translationsDialogStore = translationsDialogStore,
sessionId = tabId,
getTranslatedPageTitle = { localizedFrom, localizedTo ->
testContext.getString(
R.string.translations_bottom_sheet_title_translation_completed,
localizedFrom,
localizedTo,
)
},
)
binding.start()
browserStore.dispatch(
TranslationsAction.TranslateSuccessAction(
tabId = tab.id,
operation = TranslationOperation.TRANSLATE,
),
).joinBlocking()
verify(translationsDialogStore).dispatch(
TranslationsDialogAction.UpdateTranslated(
true,
),
)
verify(translationsDialogStore).dispatch(
TranslationsDialogAction.UpdateTranslationInProgress(
false,
),
)
verify(translationsDialogStore).dispatch(
TranslationsDialogAction.DismissDialog(
dismissDialogState = DismissDialogState.Dismiss,
),
)
}
@Test
fun `WHEN translate fetch error is sent to the browserStore THEN update translation dialog store based on operation`() =
runTestOnMain {
translationsDialogStore =
spy(TranslationsDialogStore(TranslationsDialogState()))
browserStore = BrowserStore(
BrowserState(
tabs = listOf(tab),
selectedTabId = tabId,
),
)
val binding = TranslationsDialogBinding(
browserStore = browserStore,
translationsDialogStore = translationsDialogStore,
sessionId = tabId,
getTranslatedPageTitle = { localizedFrom, localizedTo ->
testContext.getString(
R.string.translations_bottom_sheet_title_translation_completed,
localizedFrom,
localizedTo,
)
},
)
binding.start()
val fetchError = TranslationError.CouldNotLoadLanguagesError(null)
browserStore.dispatch(
TranslationsAction.TranslateExceptionAction(
tabId = tab.id,
operation = TranslationOperation.FETCH_SUPPORTED_LANGUAGES,
translationError = fetchError,
),
).joinBlocking()
verify(translationsDialogStore).dispatch(
TranslationsDialogAction.UpdateTranslationError(fetchError),
)
}
}

@ -0,0 +1,101 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.translations
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import mozilla.components.browser.state.action.TranslationsAction
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.translate.Language
import mozilla.components.concept.engine.translate.TranslationOperation
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
@RunWith(FenixRobolectricTestRunner::class)
class TranslationsDialogMiddlewareTest {
@Test
fun `GIVEN translationState WHEN FetchSupportedLanguages action is called THEN call OperationRequestedAction from BrowserStore`() =
runTest {
val browserStore = mockk<BrowserStore>(relaxed = true)
val translationsDialogMiddleware =
TranslationsDialogMiddleware(browserStore = browserStore, sessionId = "tab1")
val translationStore = TranslationsDialogStore(
initialState = TranslationsDialogState(),
middlewares = listOf(translationsDialogMiddleware),
)
translationStore.dispatch(TranslationsDialogAction.FetchSupportedLanguages).joinBlocking()
translationStore.waitUntilIdle()
verify {
browserStore.dispatch(
TranslationsAction.OperationRequestedAction(
tabId = "tab1",
operation = TranslationOperation.FETCH_SUPPORTED_LANGUAGES,
),
)
}
}
@Test
fun `GIVEN translationState WHEN TranslateAction from TranslationDialogStore is called THEN call TranslateAction from BrowserStore`() =
runTest {
val browserStore = mockk<BrowserStore>(relaxed = true)
val translationsDialogMiddleware =
TranslationsDialogMiddleware(browserStore = browserStore, sessionId = "tab1")
val translationStore = TranslationsDialogStore(
initialState = TranslationsDialogState(
initialFrom = Language("en", "English"),
initialTo = Language("fr", "France"),
),
middlewares = listOf(translationsDialogMiddleware),
)
translationStore.dispatch(TranslationsDialogAction.TranslateAction).joinBlocking()
translationStore.waitUntilIdle()
verify {
browserStore.dispatch(
TranslationsAction.TranslateAction(
tabId = "tab1",
fromLanguage = "en",
toLanguage = "fr",
options = null,
),
)
}
}
@Test
fun `GIVEN translationState WHEN RestoreTranslation from TranslationDialogStore is called THEN call TranslateRestoreAction from BrowserStore`() =
runTest {
val browserStore = mockk<BrowserStore>(relaxed = true)
val translationsDialogMiddleware =
TranslationsDialogMiddleware(browserStore = browserStore, sessionId = "tab1")
val translationStore = TranslationsDialogStore(
initialState = TranslationsDialogState(),
middlewares = listOf(translationsDialogMiddleware),
)
translationStore.dispatch(TranslationsDialogAction.RestoreTranslation).joinBlocking()
translationStore.waitUntilIdle()
verify {
browserStore.dispatch(
TranslationsAction.TranslateRestoreAction(
tabId = "tab1",
),
)
}
}
}

@ -0,0 +1,229 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.translations
import mozilla.components.concept.engine.translate.Language
import mozilla.components.concept.engine.translate.TranslationError
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
@RunWith(FenixRobolectricTestRunner::class)
class TranslationsDialogReducerTest {
@Test
fun `WHEN the reducer is called for UpdateFromSelectedLanguage THEN a new state with updated fromSelectedLanguage is returned`() {
val spanishLanguage = Language("es", "Spanish")
val englishLanguage = Language("en", "English")
val translationsDialogState = TranslationsDialogState(initialTo = spanishLanguage)
val updatedState = TranslationsDialogReducer.reduce(
translationsDialogState,
TranslationsDialogAction.UpdateFromSelectedLanguage(englishLanguage),
)
assertEquals(englishLanguage, updatedState.initialFrom)
assertEquals(PositiveButtonType.Enabled, updatedState.positiveButtonType)
val updatedStateTwo = TranslationsDialogReducer.reduce(
translationsDialogState,
TranslationsDialogAction.UpdateFromSelectedLanguage(spanishLanguage),
)
assertEquals(PositiveButtonType.Disabled, updatedStateTwo.positiveButtonType)
}
@Test
fun `WHEN the reducer is called for UpdateToSelectedLanguage THEN a new state with updated toSelectedLanguage is returned`() {
val spanishLanguage = Language("es", "Spanish")
val englishLanguage = Language("en", "English")
val translationsDialogState = TranslationsDialogState(initialFrom = spanishLanguage)
val updatedState = TranslationsDialogReducer.reduce(
translationsDialogState,
TranslationsDialogAction.UpdateToSelectedLanguage(englishLanguage),
)
assertEquals(englishLanguage, updatedState.initialTo)
assertEquals(PositiveButtonType.Enabled, updatedState.positiveButtonType)
val updatedStateTwo = TranslationsDialogReducer.reduce(
translationsDialogState,
TranslationsDialogAction.UpdateToSelectedLanguage(spanishLanguage),
)
assertEquals(PositiveButtonType.Disabled, updatedStateTwo.positiveButtonType)
}
@Test
fun `WHEN the reducer is called for UpdateTranslateToLanguages THEN a new state with updated translateToLanguages is returned`() {
val spanishLanguage = Language("es", "Spanish")
val englishLanguage = Language("en", "English")
val translationsDialogState = TranslationsDialogState()
val updatedState = TranslationsDialogReducer.reduce(
translationsDialogState,
TranslationsDialogAction.UpdateTranslateToLanguages(
listOf(
spanishLanguage,
englishLanguage,
),
),
)
assertEquals(listOf(spanishLanguage, englishLanguage), updatedState.toLanguages)
}
@Test
fun `WHEN the reducer is called for UpdateTranslateFromLanguages THEN a new state with updated translatefromLanguages is returned`() {
val spanishLanguage = Language("es", "Spanish")
val englishLanguage = Language("en", "English")
val translationsDialogState = TranslationsDialogState()
val updatedState = TranslationsDialogReducer.reduce(
translationsDialogState,
TranslationsDialogAction.UpdateTranslateFromLanguages(
listOf(
spanishLanguage,
englishLanguage,
),
),
)
assertEquals(listOf(spanishLanguage, englishLanguage), updatedState.fromLanguages)
}
@Test
fun `WHEN the reducer is called for DismissDialog THEN a new state with updated dismiss dialog is returned`() {
val translationsDialogState = TranslationsDialogState()
val updatedState = TranslationsDialogReducer.reduce(
translationsDialogState,
TranslationsDialogAction.DismissDialog(DismissDialogState.Dismiss),
)
assertEquals(DismissDialogState.Dismiss, updatedState.dismissDialogState)
}
@Test
fun `WHEN the reducer is called for UpdateInProgress THEN a new state with translation in progress is returned`() {
val translationsDialogState = TranslationsDialogState()
val updatedState = TranslationsDialogReducer.reduce(
translationsDialogState,
TranslationsDialogAction.UpdateTranslationInProgress(true),
)
assertEquals(true, updatedState.isTranslationInProgress)
assertEquals(PositiveButtonType.InProgress, updatedState.positiveButtonType)
}
@Test
fun `WHEN the reducer is called for InitTranslationsDialog THEN a new state for PositiveButtonType is returned`() {
val translationsDialogState = TranslationsDialogState()
val updatedState = TranslationsDialogReducer.reduce(
translationsDialogState,
TranslationsDialogAction.InitTranslationsDialog,
)
assertEquals(PositiveButtonType.Disabled, updatedState.positiveButtonType)
val spanishLanguage = Language("es", "Spanish")
val englishLanguage = Language("en", "English")
val translationsDialogStateTwo = TranslationsDialogState(
initialFrom = spanishLanguage,
initialTo = englishLanguage,
positiveButtonType = PositiveButtonType.Enabled,
)
val updatedStateTwo = TranslationsDialogReducer.reduce(
translationsDialogStateTwo,
TranslationsDialogAction.InitTranslationsDialog,
)
assertEquals(PositiveButtonType.Enabled, updatedStateTwo.positiveButtonType)
}
@Test
fun `WHEN the reducer is called for UpdateTranslationError THEN a new state with translation error is returned`() {
val translationsDialogState = TranslationsDialogState()
val updatedState = TranslationsDialogReducer.reduce(
translationsDialogState,
TranslationsDialogAction.UpdateTranslationError(
TranslationError.LanguageNotSupportedError(
null,
),
),
)
assertTrue(updatedState.error is TranslationError.LanguageNotSupportedError)
assertEquals(PositiveButtonType.Disabled, updatedState.positiveButtonType)
val updatedStateTwo = TranslationsDialogReducer.reduce(
translationsDialogState,
TranslationsDialogAction.UpdateTranslationError(
TranslationError.CouldNotLoadLanguagesError(
null,
),
),
)
assertTrue(updatedStateTwo.error is TranslationError.CouldNotLoadLanguagesError)
assertEquals(PositiveButtonType.Enabled, updatedStateTwo.positiveButtonType)
}
@Test
fun `WHEN the reducer is called for UpdateTranslated THEN a new state with translation translated is returned`() {
val translationsDialogState = TranslationsDialogState()
val updatedState = TranslationsDialogReducer.reduce(
translationsDialogState,
TranslationsDialogAction.UpdateTranslated(
true,
),
)
assertEquals(PositiveButtonType.Disabled, updatedState.positiveButtonType)
assertEquals(true, updatedState.isTranslated)
}
@Test
fun `WHEN the reducer is called for UpdateTranslatedPageTitle THEN a new state with translation title is returned`() {
val spanishLanguage = Language("es", "Spanish")
val englishLanguage = Language("en", "English")
val translationsDialogState =
TranslationsDialogState(initialTo = englishLanguage, initialFrom = spanishLanguage)
val updatedState = TranslationsDialogReducer.reduce(
translationsDialogState,
TranslationsDialogAction.UpdateTranslatedPageTitle(
testContext.getString(
R.string.translations_bottom_sheet_title_translation_completed,
spanishLanguage.localizedDisplayName,
englishLanguage.localizedDisplayName,
),
),
)
assertEquals(
testContext.getString(
R.string.translations_bottom_sheet_title_translation_completed,
spanishLanguage.localizedDisplayName,
englishLanguage.localizedDisplayName,
),
updatedState.translatedPageTitle,
)
}
}
Loading…
Cancel
Save