Bug 1899365 - Remove review quality checker feature and strings r=android-reviewers,delphine,007

Differential Revision: https://phabricator.services.mozilla.com/D234089
This commit is contained in:
Cathy Lu
2025-01-14 22:28:09 +00:00
parent 9bab6650da
commit e40b29c611
98 changed files with 76 additions and 10558 deletions

View File

@@ -3,12 +3,12 @@
- 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/. -->
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<color name="mozac_ui_icons_fill">#FFFFFF</color>
<!-- Star icon fill colors for mozac_ic_star_one_half_fill_20 -->
<color name="mozac_ic_star_filled">#000000</color>
<color name="mozac_ic_star_unfilled">#FFFFFF</color>
<color name="mozac_ic_star_filled" tools:ignore="UnusedResources">#000000</color>
<color name="mozac_ic_star_unfilled" tools:ignore="UnusedResources">#FFFFFF</color>
<!-- Private Mode mask icon circle fill colors for mozac_ic_private_mode_circle_fill_stroke_20 -->
<color name="mozac_ui_private_mode_circle_fill">#000000</color>

View File

@@ -202,17 +202,6 @@ search-term-groups:
enabled:
type: boolean
description: "If true, the feature shows up on the homescreen and on the new tab screen."
shopping-experience:
description: A feature that shows product review quality information.
hasExposure: true
exposureDescription: ""
variables:
enabled:
type: boolean
description: "if true, the shopping experience feature is shown to the user."
product-recommendations:
type: boolean
description: "if true, recommended products feature is enabled to be shown to the user based on their preference."
splash-screen:
description: "A feature that extends splash screen duration, allowing additional data fetching time for the app's initial run."
hasExposure: true

View File

@@ -11427,586 +11427,6 @@ sync:
- android-probes@mozilla.com
expires: never
shopping:
address_bar_icon_displayed:
type: event
description: |
The shopping icon was displayed in the address bar.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
address_bar_icon_clicked:
type: event
description: |
The shopping icon from the address bar was clicked.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_closed:
type: event
description: |
The shopping bottom sheet was closed.
extra_keys:
source:
description: |
The method used to close the bottom sheet. Possible values are: `sheet_slide`,
`link_opened`, `click_outside`, `back_pressed`, `handle_clicked`, `not_now`
or `opt_out`.
type: string
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
- https://bugzilla.mozilla.org/show_bug.cgi?id=1855150
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4060
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_displayed:
type: event
description: |
The shopping bottom sheet was displayed.
extra_keys:
view:
description: |
State of the bottom sheet. Possible values are: `half` or `full`.
type: string
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
- https://bugzilla.mozilla.org/show_bug.cgi?id=1855150
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
- https://github.com/mozilla-mobile/firefox-android/pull/4060
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_onboarding_displayed:
type: event
description: |
The shopping contextual onboarding card was displayed.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_review_quality_explainer_url_clicked:
type: event
description: |
User interacted with the learn more link from the shopping explanation card.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_show_terms_clicked:
type: event
description: |
User interacted with the terms and conditions link from the shopping contextual onboarding card.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_show_privacy_policy_clicked:
type: event
description: |
User interacted with the privacy policy link from the shopping contextual onboarding card.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_not_now_clicked:
type: event
description: |
User interacted with the opt out button from the shopping contextual onboarding card.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_opt_in_accepted:
type: event
description: |
User interacted with the opt in button from the shopping contextual onboarding card.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_show_more_recent_reviews_clicked:
type: event
description: |
User interacted with the expand "show more" button from the shopping highlights card.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_learn_more_clicked:
type: event
description: |
The user clicked on learn more link from the onboarding card.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_expand_settings:
type: event
description: |
Settings card from the bottom sheet was expanded.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_no_review_reliability_available:
type: event
description: |
No analysis card was displayed to the user.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_analyze_reviews_none_available_clicked:
type: event
description: |
User clicked on the launch analyzer link from the no analysis card.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_reanalyze_clicked:
type: event
description: |
User interacted with the launch analyzer link from the "New info to check" warning card.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_reactivated_button_clicked:
type: event
description: |
User interacted with the button to report a product is back in stock.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
product_page_visits:
type: counter
description: |
Counts number of visits to a supported retailer product page
while enrolled in either the control or treatment branches
of the shopping experiment.
send_in_pings:
- metrics
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1854501
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/4120#issuecomment-1768423370
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_powered_by_fakespot_link_clicked:
type: event
description: |
The user clicked the "Fakespot by Mozilla" link at the bottom of review checker
sheet.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1862775
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/4354#issuecomment-1794341141
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_stale_analysis_shown:
type: event
description: |
Records an event when the "New info to check" card is shown.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1862776
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/4379#issuecomment-1794925138
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_ads_impression:
type: event
description: |
The user viewed an ad in review checker for at least 1.5 seconds.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1865854
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/4604#issuecomment-1827890623
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_ads_clicked:
type: event
description: |
The user clicked an ad in review checker.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1865854
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/4604#issuecomment-1827890623
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_ads_setting_toggled:
type: event
description: |
The user toggled the ads display setting.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1865854
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/4604#issuecomment-1827890623
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
extra_keys:
action:
type: string
description: |
Whether the toggle was used to enable or disable ads. Possible values
are `enabled` and `disabled`.
metadata:
tags:
- Shopping
ads_exposure:
type: event
description: |
On a supported product page, the review checker showed analysis,
and the ads exposure pref was enabled, or review checker ads were enabled,
and when we tried to fetch an ad from the ad server, an ad was available.
Does not indicate whether the ad was actually shown.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1866992
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/4622#issuecomment-1829905076
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
surface_no_ads_available:
type: event
description: |
On a supported product page, the review checker
showed analysis, and review checker ads were enabled,
but when we tried to fetch an ad from the ad server,
no ad was available.
send_in_pings:
- events
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1866992
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/4622#issuecomment-1829905076
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
shopping.settings:
component_opted_out:
type: boolean
lifetime: application
description: |
Whether or not the user opted out of review quality check feature.
send_in_pings:
- metrics
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
nimbus_disabled_shopping:
type: boolean
lifetime: application
description: |
Whether or not Nimbus has disabled the use of the shopping component.
send_in_pings:
- metrics
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
user_has_onboarded:
type: boolean
lifetime: application
description: |
Whether or the user has completed the review quality check onboarding.
send_in_pings:
- metrics
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1843508
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/3619
- https://github.com/mozilla-mobile/firefox-android/pull/4039
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
disabled_ads:
type: boolean
lifetime: application
description: |
Indicates if the user has disabled ads.
send_in_pings:
- metrics
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1865854
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/4604#issuecomment-1827890623
data_sensitivity:
- interaction
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Shopping
fx_suggest:
ping_type:
type: string

View File

@@ -295,28 +295,6 @@ features:
type: Boolean
default: false
shopping-experience:
description: A feature that shows product review quality information.
variables:
enabled:
description: if true, the shopping experience feature is shown to the user.
type: Boolean
default: false
product-recommendations:
description: if true, recommended products feature is enabled to be shown to the user based on their preference.
type: Boolean
default: false
product-recommendations-exposure:
description: if true, we want to record recommended products inventory for opted-in users, even if product recommendations are disabled.
type: Boolean
default: false
defaults:
- channel: developer
value:
enabled: true
product-recommendations: true
product-recommendations-exposure: true
print:
description: A feature for printing from the share or browser menu.
variables:

View File

@@ -37,7 +37,6 @@ enum class BrowserDirection(@IdRes val fragmentId: Int) {
FromLoginDetailFragment(R.id.loginDetailFragment),
FromTabsTray(R.id.tabsTrayFragment),
FromRecentlyClosed(R.id.recentlyClosedFragment),
FromReviewQualityCheck(R.id.reviewQualityCheckFragment),
FromAddonsManagementFragment(R.id.addonsManagementFragment),
FromTranslationsDialogFragment(R.id.translationsDialogFragment),
FromDownloadLanguagesPreferenceFragment(R.id.downloadLanguagesPreferenceFragment),

View File

@@ -80,7 +80,6 @@ import org.mozilla.fenix.GleanMetrics.Metrics
import org.mozilla.fenix.GleanMetrics.PerfStartup
import org.mozilla.fenix.GleanMetrics.Preferences
import org.mozilla.fenix.GleanMetrics.SearchDefaultEngine
import org.mozilla.fenix.GleanMetrics.ShoppingSettings
import org.mozilla.fenix.GleanMetrics.TopSites
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.components.Core
@@ -826,13 +825,6 @@ open class FenixApplication : LocaleAwareApplication(), Provider {
}
setAutofillMetrics()
with(ShoppingSettings) {
componentOptedOut.set(!settings.isReviewQualityCheckEnabled)
nimbusDisabledShopping.set(!FxNimbus.features.shoppingExperience.value().enabled)
userHasOnboarded.set(settings.reviewQualityCheckOptInTimeInMillis != 0L)
disabledAds.set(!settings.isReviewQualityCheckProductRecommendationsEnabled)
}
}
@VisibleForTesting

View File

@@ -10,7 +10,6 @@ import android.view.View
import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
@@ -36,12 +35,10 @@ import mozilla.components.support.utils.ext.isLandscape
import mozilla.telemetry.glean.private.NoExtras
import org.mozilla.fenix.GleanMetrics.AddressToolbar
import org.mozilla.fenix.GleanMetrics.ReaderMode
import org.mozilla.fenix.GleanMetrics.Shopping
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.tabstrip.isTabStripEnabled
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction
import org.mozilla.fenix.components.appstate.AppAction.SnackbarAction
import org.mozilla.fenix.components.toolbar.BrowserToolbarView
import org.mozilla.fenix.components.toolbar.ToolbarMenu
@@ -59,8 +56,6 @@ import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.home.HomeFragment
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.settings.quicksettings.protections.cookiebanners.getCookieBannerUIMode
import org.mozilla.fenix.shopping.DefaultShoppingExperienceFeature
import org.mozilla.fenix.shopping.ReviewQualityCheckFeature
import org.mozilla.fenix.shortcut.PwaOnboardingObserver
import org.mozilla.fenix.theme.ThemeManager
@@ -71,11 +66,9 @@ import org.mozilla.fenix.theme.ThemeManager
class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
private val windowFeature = ViewBoundFeatureWrapper<WindowFeature>()
private val openInAppOnboardingObserver = ViewBoundFeatureWrapper<OpenInAppOnboardingObserver>()
private val reviewQualityCheckFeature = ViewBoundFeatureWrapper<ReviewQualityCheckFeature>()
private val translationsBinding = ViewBoundFeatureWrapper<TranslationsBinding>()
private var readerModeAvailable = false
private var reviewQualityCheckAvailable = false
private var translationsAvailable = false
private var pwaOnboardingObserver: PwaOnboardingObserver? = null
@@ -124,7 +117,6 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
initReaderMode(context, view)
initTranslationsAction(context, view)
initReviewQualityCheck(context, view)
initSharePageAction(context)
initReloadAction(context)
@@ -294,7 +286,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
contentDescription = context.getString(R.string.browser_menu_read),
contentDescriptionSelected = context.getString(R.string.browser_menu_read_close),
visible = {
readerModeAvailable && !reviewQualityCheckAvailable
readerModeAvailable
},
weight = { READER_MODE_WEIGHT },
selected = getSafeCurrentTab()?.let {
@@ -327,62 +319,6 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
)
}
private fun initReviewQualityCheck(context: Context, view: View) {
val reviewQualityCheck =
BrowserToolbar.ToggleButton(
image = AppCompatResources.getDrawable(
context,
R.drawable.mozac_ic_shopping_24,
)!!.apply {
setTint(ContextCompat.getColor(context, R.color.fx_mobile_text_color_primary))
},
imageSelected = AppCompatResources.getDrawable(
context,
R.drawable.ic_shopping_selected,
)!!,
contentDescription = context.getString(R.string.review_quality_check_open_handle_content_description),
contentDescriptionSelected =
context.getString(R.string.review_quality_check_close_handle_content_description),
visible = { reviewQualityCheckAvailable },
weight = { REVIEW_QUALITY_CHECK_WEIGHT },
listener = { _ ->
requireComponents.appStore.dispatch(
ShoppingAction.ShoppingSheetStateUpdated(expanded = true),
)
findNavController().navigate(
BrowserFragmentDirections.actionBrowserFragmentToReviewQualityCheckDialogFragment(),
)
Shopping.addressBarIconClicked.record()
},
)
browserToolbarView.view.addPageAction(reviewQualityCheck)
reviewQualityCheckFeature.set(
feature = ReviewQualityCheckFeature(
appStore = requireComponents.appStore,
browserStore = context.components.core.store,
shoppingExperienceFeature = DefaultShoppingExperienceFeature(),
onIconVisibilityChange = {
if (!reviewQualityCheckAvailable && it) {
Shopping.addressBarIconDisplayed.record()
}
reviewQualityCheckAvailable = it
safeInvalidateBrowserToolbarView()
},
onBottomSheetStateChange = {
reviewQualityCheck.setSelected(selected = it, notifyListener = false)
},
onProductPageDetected = {
Shopping.productPageVisits.add()
},
),
owner = this,
view = view,
)
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun addLeadingAction(
context: Context,

View File

@@ -48,7 +48,6 @@ class RecordedNimbusContext(
val utmCampaign: String,
val utmTerm: String,
val utmContent: String,
val isReviewCheckerEnabled: Boolean,
val androidSdkVersion: String = Build.VERSION.SDK_INT.toString(),
val appVersion: String?,
val locale: String,
@@ -87,7 +86,6 @@ class RecordedNimbusContext(
installReferrerResponseUtmCampaign = utmCampaign,
installReferrerResponseUtmTerm = utmTerm,
installReferrerResponseUtmContent = utmContent,
isReviewCheckerEnabled = isReviewCheckerEnabled,
androidSdkVersion = androidSdkVersion,
appVersion = appVersion,
locale = locale,
@@ -130,7 +128,6 @@ class RecordedNimbusContext(
"install_referrer_response_utm_campaign" to utmCampaign,
"install_referrer_response_utm_term" to utmTerm,
"install_referrer_response_utm_content" to utmContent,
"is_review_checker_enabled" to isReviewCheckerEnabled,
"android_sdk_version" to androidSdkVersion,
"app_version" to appVersion,
"locale" to locale,
@@ -179,7 +176,6 @@ class RecordedNimbusContext(
utmCampaign = settings.utmCampaign,
utmTerm = settings.utmTerm,
utmContent = settings.utmContent,
isReviewCheckerEnabled = settings.isReviewQualityCheckEnabled,
appVersion = packageInfo.versionName,
locale = deviceInfo.localeTag,
daysSinceInstall = calculatedAttributes.daysSinceInstall,
@@ -209,7 +205,6 @@ class RecordedNimbusContext(
utmCampaign = "",
utmTerm = "",
utmContent = "",
isReviewCheckerEnabled = false,
appVersion = "",
locale = "",
daysSinceInstall = 5,

View File

@@ -52,7 +52,6 @@ import org.mozilla.fenix.settings.search.SearchEngineFragmentDirections
import org.mozilla.fenix.settings.studies.StudiesFragmentDirections
import org.mozilla.fenix.settings.wallpaper.WallpaperSettingsFragmentDirections
import org.mozilla.fenix.share.AddNewDeviceFragmentDirections
import org.mozilla.fenix.shopping.ReviewQualityCheckFragmentDirections
import org.mozilla.fenix.tabstray.TabsTrayFragmentDirections
import org.mozilla.fenix.trackingprotection.TrackingProtectionPanelDialogFragmentDirections
import org.mozilla.fenix.translations.TranslationsDialogFragmentDirections
@@ -322,8 +321,6 @@ private fun getHomeNavDirections(
BrowserDirection.FromStudiesFragment -> StudiesFragmentDirections.actionGlobalBrowser()
BrowserDirection.FromReviewQualityCheck -> ReviewQualityCheckFragmentDirections.actionGlobalBrowser()
BrowserDirection.FromAddonsManagementFragment -> AddonsManagementFragmentDirections.actionGlobalBrowser()
BrowserDirection.FromTranslationsDialogFragment -> TranslationsDialogFragmentDirections.actionGlobalBrowser()

View File

@@ -39,12 +39,10 @@ object CustomAttributeProvider : JexlAttributeProvider {
fun getCustomTargetingAttributes(context: Context): JSONObject {
val settings = context.settings()
val isFirstRun = settings.isFirstNimbusRun
val isReviewCheckerEnabled = settings.isReviewQualityCheckEnabled
return JSONObject(
mapOf(
// By convention, we should use snake case.
"is_first_run" to isFirstRun,
"is_review_checker_enabled" to isReviewCheckerEnabled,
"install_referrer_response_utm_source" to settings.utmSource,
"install_referrer_response_utm_medium" to settings.utmMedium,
"install_referrer_response_utm_campaign" to settings.utmCampaign,

View File

@@ -1,48 +0,0 @@
/* 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.shopping
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.scan
import mozilla.components.lib.state.helpers.AbstractBinding
import org.mozilla.fenix.shopping.store.BottomSheetViewState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckStore
/**
* View-bound feature that requests the bottom sheet state to be changed to expanded or collapsed when
* the store state changes from [ReviewQualityCheckState.Initial] to [ReviewQualityCheckState.NotOptedIn].
*
* @param store The store to observe.
* @param isScreenReaderEnabled Used to fully expand bottom sheet when a screen reader is on.
* @param onRequestStateUpdate Callback to request the bottom sheet to be updated.
*/
class ReviewQualityCheckBottomSheetStateFeature(
store: ReviewQualityCheckStore,
private val isScreenReaderEnabled: Boolean,
private val onRequestStateUpdate: (expanded: BottomSheetViewState) -> Unit,
) : AbstractBinding<ReviewQualityCheckState>(store) {
override suspend fun onState(flow: Flow<ReviewQualityCheckState>) {
if (isScreenReaderEnabled) {
onRequestStateUpdate(BottomSheetViewState.FULL_VIEW)
} else {
val initial = Pair<ReviewQualityCheckState?, ReviewQualityCheckState?>(null, null)
flow.scan(initial) { acc, value ->
Pair(acc.second, value)
}.filter {
it.first is ReviewQualityCheckState.Initial
}.map {
when (it.second) {
is ReviewQualityCheckState.NotOptedIn -> BottomSheetViewState.FULL_VIEW
else -> BottomSheetViewState.HALF_VIEW
}
}.collect {
onRequestStateUpdate(it)
}
}
}
}

View File

@@ -1,78 +0,0 @@
/* 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.shopping
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.state.ContentState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.base.feature.LifecycleAwareFeature
import org.mozilla.fenix.components.AppStore
private const val DEBOUNCE_TIMEOUT_MILLIS = 200L
/**
* Feature implementation that provides review quality check information for supported product
* pages.
*
* @param appStore Reference to the application's [AppStore].
* @param browserStore Reference to the application's [BrowserStore].
* @param shoppingExperienceFeature Reference to the [ShoppingExperienceFeature].
* @param onIconVisibilityChange Invoked when shopping icon visibility changes based on feature
* flag and when the loaded page is a supported product page.
* @param onBottomSheetStateChange Invoked when the bottom sheet is collapsed or expanded.
* @param debounceTimeoutMillis Function that returns the debounce timeout in milliseconds. This
* make it possible to wait till [ContentState.isProductUrl] is stable before invoking
* [onIconVisibilityChange].
* @param onProductPageDetected Invoked when a product page is detected and loaded. Used to
* detect when to send telemetry for shopping.product_page_visits.
*/
@OptIn(FlowPreview::class)
class ReviewQualityCheckFeature(
private val appStore: AppStore,
private val browserStore: BrowserStore,
private val shoppingExperienceFeature: ShoppingExperienceFeature,
private val onIconVisibilityChange: (isAvailable: Boolean) -> Unit,
private val onBottomSheetStateChange: (isExpanded: Boolean) -> Unit,
private val debounceTimeoutMillis: (Boolean) -> Long = { if (it) DEBOUNCE_TIMEOUT_MILLIS else 0 },
private val onProductPageDetected: () -> Unit,
) : LifecycleAwareFeature {
private var scope: CoroutineScope? = null
private var appStoreScope: CoroutineScope? = null
override fun start() {
scope = browserStore.flowScoped { flow ->
flow.mapNotNull { it.selectedTab }
.map { it.content.isProductUrl && !it.content.loading }
.distinctUntilChanged()
.debounce(debounceTimeoutMillis)
.collect {
if (it) {
onProductPageDetected()
}
onIconVisibilityChange(shoppingExperienceFeature.isEnabled && it)
}
}
appStoreScope = appStore.flowScoped { flow ->
flow.mapNotNull { it.shoppingState.shoppingSheetExpanded }
.distinctUntilChanged()
.collect(onBottomSheetStateChange)
}
}
override fun stop() {
scope?.cancel()
appStoreScope?.cancel()
}
}

View File

@@ -1,147 +0,0 @@
/* 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.shopping
import android.app.Dialog
import android.content.DialogInterface
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.addCallback
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.navigation.fragment.NavHostFragment.Companion.findNavController
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import mozilla.components.support.ktx.android.content.isScreenReaderEnabled
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.lazyStore
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.shopping.di.ReviewQualityCheckMiddlewareProvider
import org.mozilla.fenix.shopping.store.BottomSheetDismissSource
import org.mozilla.fenix.shopping.store.BottomSheetViewState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
import org.mozilla.fenix.shopping.store.ReviewQualityCheckStore
import org.mozilla.fenix.shopping.ui.ReviewQualityCheckBottomSheet
import org.mozilla.fenix.theme.FirefoxTheme
/**
* A bottom sheet fragment displaying Review Quality Check information.
*/
class ReviewQualityCheckFragment : BottomSheetDialogFragment() {
private var behavior: BottomSheetBehavior<View>? = null
private val bottomSheetStateFeature =
ViewBoundFeatureWrapper<ReviewQualityCheckBottomSheetStateFeature>()
private val store by lazyStore { viewModelScope ->
ReviewQualityCheckStore(
middleware = ReviewQualityCheckMiddlewareProvider.provideMiddleware(
settings = requireComponents.settings,
browserStore = requireComponents.core.store,
appStore = requireComponents.appStore,
context = requireContext().applicationContext,
scope = viewModelScope,
),
)
}
private var dismissSource: BottomSheetDismissSource =
BottomSheetDismissSource.CLICK_OUTSIDE
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
// The bottom sheet enters STATE_HIDDEN when onDismiss is called. Because this is not
// the case when the user clicks outside, we should check for any other dismissal sources.
if (newState == BottomSheetBehavior.STATE_HIDDEN &&
dismissSource == BottomSheetDismissSource.CLICK_OUTSIDE
) {
dismissSource = BottomSheetDismissSource.SLIDE
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
// no-op
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
super.onCreateDialog(savedInstanceState).apply {
setOnShowListener {
val bottomSheet =
findViewById<View?>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.setBackgroundResource(android.R.color.transparent)
behavior = BottomSheetBehavior.from(bottomSheet).apply {
addBottomSheetCallback(bottomSheetCallback)
setPeekHeightToHalfScreenHeight()
}
}
(this as? BottomSheetDialog)?.onBackPressedDispatcher?.addCallback(this@ReviewQualityCheckFragment) {
dismissSource = BottomSheetDismissSource.BACK_PRESSED
findNavController(this@ReviewQualityCheckFragment).navigateUp()
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
behavior?.setPeekHeightToHalfScreenHeight()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View = ComposeView(requireContext()).apply {
setContent {
FirefoxTheme {
ReviewQualityCheckBottomSheet(
store = store,
onRequestDismiss = {
dismissSource = it
behavior?.state = BottomSheetBehavior.STATE_HIDDEN
},
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()),
)
}
}
}
override fun onDestroy() {
super.onDestroy()
behavior?.removeBottomSheetCallback(bottomSheetCallback)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bottomSheetStateFeature.set(
feature = ReviewQualityCheckBottomSheetStateFeature(
store,
requireContext().isScreenReaderEnabled,
) { bottomSheetState ->
if (bottomSheetState == BottomSheetViewState.FULL_VIEW) {
behavior?.state = BottomSheetBehavior.STATE_EXPANDED
}
store.dispatch(
ReviewQualityCheckAction.BottomSheetDisplayed(bottomSheetState),
)
},
owner = viewLifecycleOwner,
view = view,
)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
requireComponents.appStore.dispatch(AppAction.ShoppingAction.ShoppingSheetStateUpdated(expanded = false))
store.dispatch(ReviewQualityCheckAction.BottomSheetClosed(dismissSource))
}
private fun BottomSheetBehavior<View>.setPeekHeightToHalfScreenHeight() {
peekHeight = resources.displayMetrics.heightPixels / 2
}
}

View File

@@ -1,35 +0,0 @@
/* 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.shopping
import org.mozilla.fenix.nimbus.FxNimbus
/**
* An abstraction for shopping experience feature flag.
*/
interface ShoppingExperienceFeature {
/**
* Returns true if the shopping experience feature is enabled.
*/
val isEnabled: Boolean
/**
* Returns true if product recommendations exposure nimbus flag is enabled.
*/
val isProductRecommendationsExposureEnabled: Boolean
}
/**
* The default implementation of [ShoppingExperienceFeature].
*/
class DefaultShoppingExperienceFeature : ShoppingExperienceFeature {
override val isEnabled
get() = FxNimbus.features.shoppingExperience.value().enabled
override val isProductRecommendationsExposureEnabled: Boolean
get() = FxNimbus.features.shoppingExperience.value().productRecommendationsExposure
}

View File

@@ -1,96 +0,0 @@
/* 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.shopping.di
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.feature.tabs.TabsUseCases
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.shopping.DefaultShoppingExperienceFeature
import org.mozilla.fenix.shopping.middleware.DefaultNetworkChecker
import org.mozilla.fenix.shopping.middleware.DefaultReviewQualityCheckPreferences
import org.mozilla.fenix.shopping.middleware.DefaultReviewQualityCheckService
import org.mozilla.fenix.shopping.middleware.DefaultReviewQualityCheckTelemetryService
import org.mozilla.fenix.shopping.middleware.DefaultReviewQualityCheckVendorsService
import org.mozilla.fenix.shopping.middleware.GetReviewQualityCheckSumoUrl
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckNavigationMiddleware
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckNetworkMiddleware
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckPreferencesMiddleware
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckTelemetryMiddleware
import org.mozilla.fenix.shopping.store.ReviewQualityCheckMiddleware
import org.mozilla.fenix.utils.Settings
/**
* Provides middleware for review quality check store.
*/
object ReviewQualityCheckMiddlewareProvider {
/**
* Provides middlewares for review quality check feature.
*
* @param settings The [Settings] instance to use.
* @param browserStore The [BrowserStore] instance to access state.
* @param appStore The [AppStore] instance to access state.
* @param context The [Context] instance to use.
* @param scope The [CoroutineScope] to use for launching coroutines.
*/
fun provideMiddleware(
settings: Settings,
browserStore: BrowserStore,
appStore: AppStore,
context: Context,
scope: CoroutineScope,
): List<ReviewQualityCheckMiddleware> =
listOf(
providePreferencesMiddleware(settings, browserStore, appStore, scope),
provideNetworkMiddleware(browserStore, context, scope),
provideNavigationMiddleware(TabsUseCases.SelectOrAddUseCase(browserStore), context),
provideTelemetryMiddleware(browserStore, appStore, scope),
)
private fun providePreferencesMiddleware(
settings: Settings,
browserStore: BrowserStore,
appStore: AppStore,
scope: CoroutineScope,
) = ReviewQualityCheckPreferencesMiddleware(
reviewQualityCheckPreferences = DefaultReviewQualityCheckPreferences(settings),
reviewQualityCheckVendorsService = DefaultReviewQualityCheckVendorsService(browserStore),
appStore = appStore,
shoppingExperienceFeature = DefaultShoppingExperienceFeature(),
scope = scope,
)
private fun provideNetworkMiddleware(
browserStore: BrowserStore,
context: Context,
scope: CoroutineScope,
) = ReviewQualityCheckNetworkMiddleware(
reviewQualityCheckService = DefaultReviewQualityCheckService(browserStore),
networkChecker = DefaultNetworkChecker(context),
scope = scope,
)
private fun provideNavigationMiddleware(
selectOrAddUseCase: TabsUseCases.SelectOrAddUseCase,
context: Context,
) = ReviewQualityCheckNavigationMiddleware(
selectOrAddUseCase = selectOrAddUseCase,
GetReviewQualityCheckSumoUrl(context),
)
private fun provideTelemetryMiddleware(
browserStore: BrowserStore,
appStore: AppStore,
scope: CoroutineScope,
) =
ReviewQualityCheckTelemetryMiddleware(
telemetryService = DefaultReviewQualityCheckTelemetryService(browserStore),
browserStore = browserStore,
appStore = appStore,
scope = scope,
)
}

View File

@@ -1,12 +0,0 @@
/* 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.shopping.middleware
/**
* Converts a string to an enum value, ignoring case and replacing spaces with underscores.
* If the string does not match any of the enum values, the default value is returned.
*/
inline fun <reified T : Enum<T>> String.asEnumOrDefault(defaultValue: T? = null): T? =
enumValues<T>().firstOrNull { it.name.equals(this.replace(" ", "_"), ignoreCase = true) } ?: defaultValue

View File

@@ -1,39 +0,0 @@
/* 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.shopping.middleware
import android.content.Context
import android.net.Uri
import org.mozilla.fenix.settings.SupportUtils
private const val PARAM_UTM_CAMPAIGN_KEY = "utm_campaign"
private const val PARAM_UTM_CAMPAIGN_VALUE = "fakespot-by-mozilla"
private const val PARAM_UTM_TERM_KEY = "utm_term"
private const val PARAM_UTM_TERM_VALUE = "core-sheet"
/**
* Class used to retrieve the SUMO review quality check link.
*
* @param context Context used to localize the SUMO url.
*/
class GetReviewQualityCheckSumoUrl(
private val context: Context,
) {
private val url by lazy {
appendUTMParams(
SupportUtils.getSumoURLForTopic(context, SupportUtils.SumoTopic.REVIEW_QUALITY_CHECK),
)
}
/**
* Returns the review quality check SUMO url.
*/
operator fun invoke(): String = url
private fun appendUTMParams(url: String): String = Uri.parse(url).buildUpon()
.appendQueryParameter(PARAM_UTM_CAMPAIGN_KEY, PARAM_UTM_CAMPAIGN_VALUE)
.appendQueryParameter(PARAM_UTM_TERM_KEY, PARAM_UTM_TERM_VALUE)
.build().toString()
}

View File

@@ -1,33 +0,0 @@
/* 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.shopping.middleware
import android.content.Context
import android.net.ConnectivityManager
import androidx.core.content.getSystemService
import org.mozilla.fenix.ext.isOnline
/**
* Checks if the device is connected to the internet.
*/
interface NetworkChecker {
/**
* @return true if the device is connected to the internet, false otherwise.
*/
fun isConnected(): Boolean
}
/**
* @see [NetworkChecker].
*/
class DefaultNetworkChecker(private val context: Context) : NetworkChecker {
private val connectivityManager by lazy { context.getSystemService<ConnectivityManager>() }
override fun isConnected(): Boolean {
return connectivityManager?.isOnline() ?: false
}
}

View File

@@ -1,75 +0,0 @@
/* 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.shopping.middleware
import mozilla.components.concept.engine.shopping.Highlight
import mozilla.components.concept.engine.shopping.ProductAnalysis
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.HighlightType
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.HighlightsInfo
/**
* Maps [ProductAnalysis] to [ProductReviewState].
*/
fun ProductAnalysis?.toProductReviewState(): ProductReviewState =
this?.toProductReview() ?: ProductReviewState.Error.GenericError
private fun ProductAnalysis.toProductReview(): ProductReviewState =
if (pageNotSupported) {
ProductReviewState.Error.UnsupportedProductTypeError
} else if (productId == null) {
if (needsAnalysis) {
ProductReviewState.NoAnalysisPresent()
} else {
ProductReviewState.Error.GenericError
}
} else if (deletedProductReported) {
ProductReviewState.Error.ProductAlreadyReported
} else if (deletedProduct) {
ProductReviewState.Error.ProductNotAvailable
} else if (notEnoughReviews && !needsAnalysis) {
ProductReviewState.Error.NotEnoughReviews
} else {
val mappedRating = adjustedRating?.toFloat()
val mappedGrade = grade?.asEnumOrDefault<ReviewQualityCheckState.Grade>()
val mappedHighlights = highlights?.toHighlights()?.toSortedMap()
if (mappedGrade == null && mappedRating == null && mappedHighlights == null) {
ProductReviewState.NoAnalysisPresent()
} else {
ProductReviewState.AnalysisPresent(
productId = productId!!,
reviewGrade = mappedGrade,
analysisStatus = needsAnalysis.toAnalysisStatus(),
adjustedRating = mappedRating,
productUrl = analysisURL!!,
highlightsInfo = mappedHighlights?.let { HighlightsInfo(it) },
)
}
}
private fun Boolean.toAnalysisStatus(): AnalysisStatus =
when (this) {
true -> AnalysisStatus.NeedsAnalysis
false -> AnalysisStatus.UpToDate
}
private fun Highlight.toHighlights(): Map<HighlightType, List<String>>? =
HighlightType.entries
.associateWith { highlightsForType(it) }
.filterValues { it != null }
.mapValues { it.value!! }
.ifEmpty { null }
private fun Highlight.highlightsForType(highlightType: HighlightType) =
when (highlightType) {
HighlightType.QUALITY -> quality
HighlightType.PRICE -> price
HighlightType.SHIPPING -> shipping
HighlightType.PACKAGING_AND_APPEARANCE -> appearance
HighlightType.COMPETITIVENESS -> competitiveness
}

View File

@@ -1,50 +0,0 @@
/* 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.shopping.middleware
import mozilla.components.concept.engine.shopping.ProductRecommendation
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.RecommendedProductState
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
private const val MINIMUM_FRACTION_DIGITS = 0
private const val MAXIMUM_FRACTION_DIGITS = 2
/**
* Maps [ProductRecommendation] to [RecommendedProductState].
*/
fun ProductRecommendation?.toRecommendedProductState(): RecommendedProductState =
this?.toRecommendedProduct() ?: RecommendedProductState.Initial
private fun ProductRecommendation.toRecommendedProduct(): RecommendedProductState.Product =
RecommendedProductState.Product(
aid = aid,
name = name,
productUrl = url,
imageUrl = imageUrl,
formattedPrice = price.toDouble().toFormattedAmount(currency),
reviewGrade = grade.asEnumOrDefault<ReviewQualityCheckState.Grade>()!!,
adjustedRating = adjustedRating.toFloat(),
isSponsored = sponsored,
analysisUrl = analysisUrl,
)
private fun Double.toFormattedAmount(currencyCode: String): String =
mapCurrencyCodeToNumberFormat(currencyCode).apply {
minimumFractionDigits = MINIMUM_FRACTION_DIGITS
maximumFractionDigits = MAXIMUM_FRACTION_DIGITS
}.format(this)
private fun mapCurrencyCodeToNumberFormat(currencyCode: String): NumberFormat =
try {
val currency = Currency.getInstance(currencyCode)
NumberFormat.getCurrencyInstance(Locale.getDefault()).apply {
this.currency = currency
}
} catch (e: IllegalArgumentException) {
NumberFormat.getNumberInstance()
}

View File

@@ -1,45 +0,0 @@
/* 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.shopping.middleware
import kotlinx.coroutines.delay
private const val MAX_RETRIES = 10
private const val INITIAL_DELAY_MS = 2000L
private const val MAX_DELAY_MS = 20000L
private const val FACTOR = 2.0
/**
* Retry a suspend function until it returns a value that satisfies the predicate.
*
* @param maxRetries The maximum number of retries.
* @param initialDelayMs The initial delay in milliseconds.
* @param maxDelayMs The maximum delay in milliseconds.
* @param factor The factor to increase the delay by.
* @param predicate The predicate to check the result against.
* @param block The function to retry.
*/
suspend fun <T> retry(
maxRetries: Int = MAX_RETRIES,
initialDelayMs: Long = INITIAL_DELAY_MS,
maxDelayMs: Long = MAX_DELAY_MS,
factor: Double = FACTOR,
predicate: (T) -> Boolean,
block: suspend () -> T,
): T {
var delayTime = initialDelayMs
var data: T = block()
repeat(maxRetries - 1) {
if (predicate(data)) {
delay(delayTime)
data = block()
delayTime = (delayTime * factor).toLong().coerceAtMost(maxDelayMs)
} else {
return data
}
}
return data
}

View File

@@ -1,71 +0,0 @@
/* 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.shopping.middleware
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.lib.state.MiddlewareContext
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
import org.mozilla.fenix.shopping.store.ReviewQualityCheckMiddleware
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
private const val POWERED_BY_URL =
"https://www.fakespot.com/review-checker?utm_source=review-checker" +
"&utm_campaign=fakespot-by-mozilla&utm_medium=inproduct&utm_term=core-sheet"
private const val PRIVACY_POLICY_URL = "https://www.mozilla.org/en-US/privacy/firefox/#review-checker" +
"?utm_source=review-checker&utm_campaign=privacy-policy&utm_medium=in-product&utm_term=opt-in-screen"
private const val TERMS_OF_USE_URL = "https://www.fakespot.com/terms"
/**
* Middleware that handles navigation events for the review quality check feature.
*
* @param selectOrAddUseCase UseCase instance used to open new tabs.
* @param getReviewQualityCheckSumoUrl Instance used to retrieve the learn more SUMO link.
*/
class ReviewQualityCheckNavigationMiddleware(
private val selectOrAddUseCase: TabsUseCases.SelectOrAddUseCase,
private val getReviewQualityCheckSumoUrl: GetReviewQualityCheckSumoUrl,
) : ReviewQualityCheckMiddleware {
override fun invoke(
context: MiddlewareContext<ReviewQualityCheckState, ReviewQualityCheckAction>,
next: (ReviewQualityCheckAction) -> Unit,
action: ReviewQualityCheckAction,
) {
next(action)
when (action) {
is ReviewQualityCheckAction.NavigationMiddlewareAction -> processAction(action)
else -> {
// no-op
}
}
}
private fun processAction(
action: ReviewQualityCheckAction.NavigationMiddlewareAction,
) {
selectOrAddUseCase.invoke(actionToUrl(action))
}
/**
* Used to find the corresponding url to the open link action.
*
* @param action Used to find the corresponding url.
*/
private fun actionToUrl(
action: ReviewQualityCheckAction.NavigationMiddlewareAction,
) = when (action) {
is ReviewQualityCheckAction.OpenExplainerLearnMoreLink,
ReviewQualityCheckAction.OpenOnboardingLearnMoreLink,
-> getReviewQualityCheckSumoUrl()
is ReviewQualityCheckAction.OpenOnboardingTermsLink -> TERMS_OF_USE_URL
is ReviewQualityCheckAction.OpenOnboardingPrivacyPolicyLink -> PRIVACY_POLICY_URL
is ReviewQualityCheckAction.OpenPoweredByLink -> POWERED_BY_URL
is ReviewQualityCheckAction.RecommendedProductClick -> action.productUrl
}
}

View File

@@ -1,185 +0,0 @@
/* 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.shopping.middleware
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import mozilla.components.lib.state.MiddlewareContext
import mozilla.components.lib.state.Store
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction.FetchProductAnalysis
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction.RetryProductAnalysis
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction.UpdateRecommendedProduct
import org.mozilla.fenix.shopping.store.ReviewQualityCheckMiddleware
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
/**
* Middleware that handles network requests for the review quality check feature.
*
* @param reviewQualityCheckService The service that handles the network requests.
* @param networkChecker The [NetworkChecker] instance to check the network status.
* @param scope The [CoroutineScope] that will be used to launch coroutines.
*/
class ReviewQualityCheckNetworkMiddleware(
private val reviewQualityCheckService: ReviewQualityCheckService,
private val networkChecker: NetworkChecker,
private val scope: CoroutineScope,
) : ReviewQualityCheckMiddleware {
override fun invoke(
context: MiddlewareContext<ReviewQualityCheckState, ReviewQualityCheckAction>,
next: (ReviewQualityCheckAction) -> Unit,
action: ReviewQualityCheckAction,
) {
next(action)
when (action) {
is ReviewQualityCheckAction.NetworkAction -> processAction(context.store, action)
else -> {
// no-op
}
}
}
private fun processAction(
store: Store<ReviewQualityCheckState, ReviewQualityCheckAction>,
action: ReviewQualityCheckAction.NetworkAction,
) {
if (!networkChecker.isConnected()) {
store.updateProductReviewState(ProductReviewState.Error.NetworkError)
return
}
scope.launch {
when (action) {
FetchProductAnalysis, RetryProductAnalysis -> {
store.onFetch()
}
ReviewQualityCheckAction.ReanalyzeProduct,
ReviewQualityCheckAction.AnalyzeProduct,
ReviewQualityCheckAction.RestoreReanalysis,
-> {
store.onReanalyze()
}
ReviewQualityCheckAction.ReportProductBackInStock -> {
val status = reviewQualityCheckService.reportBackInStock()
if (status == ReportBackInStockStatusDto.NOT_DELETED) {
store.onFetch()
}
}
ReviewQualityCheckAction.ToggleProductRecommendation -> {
val state = store.state
if (state is ReviewQualityCheckState.OptedIn &&
state.productReviewState is ProductReviewState.AnalysisPresent &&
state.productRecommendationsPreference == true
) {
store.updateRecommendedProductState()
}
}
}
}
}
private suspend fun Store<ReviewQualityCheckState, ReviewQualityCheckAction>.onFetch() {
val productAnalysis = reviewQualityCheckService.fetchProductReview()
val productReviewState = productAnalysis.toProductReviewState()
// Here the ProductReviewState should only updated after the analysis status API
// returns a result. This makes sure that the UI doesn't show the reanalyse
// button in case the product analysis is already in progress on the backend.
if (productReviewState.isAnalysisPresentOrNoAnalysisPresent() &&
reviewQualityCheckService.analysisStatus()?.status.isPendingOrInProgress()
) {
updateProductReviewState(productReviewState, true)
dispatch(ReviewQualityCheckAction.RestoreReanalysis)
} else {
updateProductReviewState(productReviewState)
}
if (productReviewState is ProductReviewState.AnalysisPresent) {
updateRecommendedProductState()
}
}
private suspend fun Store<ReviewQualityCheckState, ReviewQualityCheckAction>.onReanalyze() {
val reanalysis = reviewQualityCheckService.reanalyzeProduct()
if (reanalysis == null) {
updateProductReviewState(ProductReviewState.Error.GenericError)
return
}
val statusProgress = pollForAnalysisStatus {
dispatch(ReviewQualityCheckAction.UpdateAnalysisProgress(it))
}
if (statusProgress == null ||
statusProgress.status == AnalysisStatusDto.PENDING ||
statusProgress.status == AnalysisStatusDto.IN_PROGRESS
) {
// poll failed, reset to previous state
val state = this.state
if (state is ReviewQualityCheckState.OptedIn) {
if (state.productReviewState is ProductReviewState.NoAnalysisPresent) {
updateProductReviewState(ProductReviewState.NoAnalysisPresent())
} else if (state.productReviewState is ProductReviewState.AnalysisPresent) {
updateProductReviewState(
state.productReviewState.copy(analysisStatus = AnalysisStatus.NeedsAnalysis),
)
}
}
} else {
// poll succeeded, update state
val productAnalysis = reviewQualityCheckService.fetchProductReview()
val productReviewState = productAnalysis.toProductReviewState()
updateProductReviewState(productReviewState)
}
}
private suspend fun pollForAnalysisStatus(
onEachSuccessfulPoll: (progress: Double) -> Unit,
): AnalysisStatusProgressDto? =
retry(
predicate = { it?.status.isPendingOrInProgress() },
block = {
reviewQualityCheckService.analysisStatus()?.also {
onEachSuccessfulPoll(it.progress)
}
},
)
private fun Store<ReviewQualityCheckState, ReviewQualityCheckAction>.updateProductReviewState(
productReviewState: ProductReviewState,
restoreAnalysis: Boolean = false,
) {
dispatch(ReviewQualityCheckAction.UpdateProductReview(productReviewState, restoreAnalysis))
}
private fun ProductReviewState.isAnalysisPresentOrNoAnalysisPresent() =
this is ProductReviewState.AnalysisPresent || this is ProductReviewState.NoAnalysisPresent
private suspend fun Store<ReviewQualityCheckState, ReviewQualityCheckAction>.updateRecommendedProductState() {
val currentState = state
if (currentState is ReviewQualityCheckState.OptedIn &&
(currentState.productRecommendationsExposure || (currentState.productRecommendationsPreference == true))
) {
val productRecommendation = reviewQualityCheckService.productRecommendation(
currentState.productRecommendationsPreference ?: false,
)
if (currentState.productRecommendationsPreference == true) {
productRecommendation.toRecommendedProductState().also {
dispatch(UpdateRecommendedProduct(it))
}
}
}
}
private fun AnalysisStatusDto?.isPendingOrInProgress(): Boolean =
this == AnalysisStatusDto.PENDING || this == AnalysisStatusDto.IN_PROGRESS
}

View File

@@ -1,67 +0,0 @@
/* 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.shopping.middleware
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.utils.Settings
/**
* Interface to get and set preferences for the review quality check feature.
*/
interface ReviewQualityCheckPreferences {
/**
* Returns true if the user has opted in to the review quality check feature.
*/
suspend fun enabled(): Boolean
/**
* Returns true if the user has turned on product recommendations, false if turned off by the
* user, null if the product recommendations feature is disabled.
*/
suspend fun productRecommendationsEnabled(): Boolean?
/**
* Sets whether the user has opted in to the review quality check feature.
*/
suspend fun setEnabled(isEnabled: Boolean)
/**
* Sets user preference to turn on/off product recommendations.
*/
suspend fun setProductRecommendationsEnabled(isEnabled: Boolean)
}
/**
* Implementation of [ReviewQualityCheckPreferences] that uses [Settings] to store/fetch
* preferences.
*
* @param settings The [Settings] instance to use.
*/
class DefaultReviewQualityCheckPreferences(
private val settings: Settings,
) : ReviewQualityCheckPreferences {
override suspend fun enabled(): Boolean = withContext(Dispatchers.IO) {
settings.isReviewQualityCheckEnabled
}
override suspend fun productRecommendationsEnabled(): Boolean? = withContext(Dispatchers.IO) {
if (FxNimbus.features.shoppingExperience.value().productRecommendations) {
settings.isReviewQualityCheckProductRecommendationsEnabled
} else {
null
}
}
override suspend fun setEnabled(isEnabled: Boolean) {
settings.isReviewQualityCheckEnabled = isEnabled
}
override suspend fun setProductRecommendationsEnabled(isEnabled: Boolean) {
settings.isReviewQualityCheckProductRecommendationsEnabled = isEnabled
}
}

View File

@@ -1,184 +0,0 @@
/* 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.shopping.middleware
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import mozilla.components.lib.state.MiddlewareContext
import mozilla.components.lib.state.Store
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction
import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction.HighlightsCardExpanded
import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction.InfoCardExpanded
import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction.SettingsCardExpanded
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.components.appstate.shopping.ShoppingState.CardState
import org.mozilla.fenix.shopping.ShoppingExperienceFeature
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
import org.mozilla.fenix.shopping.store.ReviewQualityCheckMiddleware
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn
/**
* Middleware for getting and setting review quality check user preferences.
*
* @param reviewQualityCheckPreferences The [ReviewQualityCheckPreferences] instance to get and
* set preferences for the review quality check feature.
* @param reviewQualityCheckVendorsService The [ReviewQualityCheckVendorsService] instance for
* getting the list of product vendors.
* @param appStore The [AppStore] instance for dispatching [ShoppingAction]s.
* @param shoppingExperienceFeature The [ShoppingExperienceFeature] instance to get feature flags.
* @param scope The [CoroutineScope] to use for launching coroutines.
*/
class ReviewQualityCheckPreferencesMiddleware(
private val reviewQualityCheckPreferences: ReviewQualityCheckPreferences,
private val reviewQualityCheckVendorsService: ReviewQualityCheckVendorsService,
private val appStore: AppStore,
private val shoppingExperienceFeature: ShoppingExperienceFeature,
private val scope: CoroutineScope,
) : ReviewQualityCheckMiddleware {
override fun invoke(
context: MiddlewareContext<ReviewQualityCheckState, ReviewQualityCheckAction>,
next: (ReviewQualityCheckAction) -> Unit,
action: ReviewQualityCheckAction,
) {
next(action)
when (action) {
is ReviewQualityCheckAction.PreferencesMiddlewareAction -> {
processAction(context.store, action)
}
else -> {
// no-op
}
}
}
@Suppress("LongMethod")
private fun processAction(
store: Store<ReviewQualityCheckState, ReviewQualityCheckAction>,
action: ReviewQualityCheckAction.PreferencesMiddlewareAction,
) {
when (action) {
is ReviewQualityCheckAction.Init -> {
scope.launch {
val hasUserOptedIn = reviewQualityCheckPreferences.enabled()
val isProductRecommendationsEnabled =
reviewQualityCheckPreferences.productRecommendationsEnabled()
val updateUserPreferences = if (hasUserOptedIn) {
val savedCardState =
reviewQualityCheckVendorsService.selectedTabUrl()?.let {
appStore.state.shoppingState.productCardState.getOrElse(it) { CardState() }
} ?: CardState()
ReviewQualityCheckAction.OptInCompleted(
isProductRecommendationsEnabled = isProductRecommendationsEnabled,
productRecommendationsExposure =
shoppingExperienceFeature.isProductRecommendationsExposureEnabled,
productVendor = reviewQualityCheckVendorsService.productVendor(),
isHighlightsExpanded = savedCardState.isHighlightsExpanded,
isInfoExpanded = savedCardState.isInfoExpanded,
isSettingsExpanded = savedCardState.isSettingsExpanded,
)
} else {
val productVendors = reviewQualityCheckVendorsService.productVendors()
ReviewQualityCheckAction.OptOutCompleted(productVendors)
}
store.dispatch(updateUserPreferences)
}
}
ReviewQualityCheckAction.OptIn -> {
scope.launch {
val isProductRecommendationsEnabled =
reviewQualityCheckPreferences.productRecommendationsEnabled()
store.dispatch(
ReviewQualityCheckAction.OptInCompleted(
isProductRecommendationsEnabled = isProductRecommendationsEnabled,
productRecommendationsExposure =
shoppingExperienceFeature.isProductRecommendationsExposureEnabled,
productVendor = reviewQualityCheckVendorsService.productVendor(),
isHighlightsExpanded = false,
isInfoExpanded = false,
isSettingsExpanded = false,
),
)
// Update the preference
reviewQualityCheckPreferences.setEnabled(true)
}
}
ReviewQualityCheckAction.OptOut -> {
scope.launch {
// Update the preference
reviewQualityCheckPreferences.setEnabled(false)
}
}
ReviewQualityCheckAction.ToggleProductRecommendation -> {
scope.launch {
val productRecommendationsEnabled =
reviewQualityCheckPreferences.productRecommendationsEnabled()
if (productRecommendationsEnabled != null) {
reviewQualityCheckPreferences.setProductRecommendationsEnabled(
!productRecommendationsEnabled,
)
}
}
}
ReviewQualityCheckAction.ExpandCollapseHighlights -> {
appStore.dispatchShoppingAction(
reviewQualityCheckState = store.state,
action = { productPageUrl, optedIn ->
HighlightsCardExpanded(
productPageUrl = productPageUrl,
expanded = optedIn.isHighlightsExpanded,
)
},
)
}
ReviewQualityCheckAction.ExpandCollapseInfo -> {
appStore.dispatchShoppingAction(
reviewQualityCheckState = store.state,
action = { productPageUrl, optedIn ->
InfoCardExpanded(
productPageUrl = productPageUrl,
expanded = optedIn.isInfoExpanded,
)
},
)
}
ReviewQualityCheckAction.ExpandCollapseSettings -> {
appStore.dispatchShoppingAction(
reviewQualityCheckState = store.state,
action = { productPageUrl, optedIn ->
SettingsCardExpanded(
productPageUrl = productPageUrl,
expanded = optedIn.isSettingsExpanded,
)
},
)
}
}
}
private fun Store<AppState, AppAction>.dispatchShoppingAction(
reviewQualityCheckState: ReviewQualityCheckState,
action: (productPageUrl: String, optedIn: OptedIn) -> ShoppingAction,
) {
if (reviewQualityCheckState is OptedIn) {
reviewQualityCheckVendorsService.selectedTabUrl()?.let {
dispatch(action(it, reviewQualityCheckState))
}
}
}
}

View File

@@ -1,235 +0,0 @@
/* 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.shopping.middleware
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.shopping.ProductAnalysis
import mozilla.components.concept.engine.shopping.ProductRecommendation
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.GleanMetrics.Shopping
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* Service that handles the network requests for the review quality check feature.
*/
interface ReviewQualityCheckService {
/**
* Fetches the product review for the current tab.
*
* @return [ProductAnalysis] if the request succeeds, null otherwise.
*/
suspend fun fetchProductReview(): ProductAnalysis?
/**
* Triggers a reanalysis of the product review for the current tab.
*
* @return [AnalysisStatusDto] if the request succeeds, null otherwise.
*/
suspend fun reanalyzeProduct(): AnalysisStatusDto?
/**
* Fetches the status of the product review for the current tab.
*
* @return [AnalysisStatusProgressDto] if the request succeeds, null otherwise.
*/
suspend fun analysisStatus(): AnalysisStatusProgressDto?
/**
* Fetches product recommendations related to the product user is browsing in the current tab.
*
* @return [ProductRecommendation] if request succeeds, null otherwise.
*/
suspend fun productRecommendation(shouldRecordAvailableTelemetry: Boolean): ProductRecommendation?
/**
* Reports that a product is back in stock.
*
* @return [ReportBackInStockStatusDto] if the request succeeds, null otherwise.
*/
suspend fun reportBackInStock(): ReportBackInStockStatusDto?
}
/**
* Service that handles the network requests for the review quality check feature.
*
* @param browserStore Reference to the application's [BrowserStore] to access state.
*/
class DefaultReviewQualityCheckService(
private val browserStore: BrowserStore,
) : ReviewQualityCheckService {
private val recommendationsCache: MutableMap<String, ProductRecommendation> = mutableMapOf()
private val logger = Logger("DefaultReviewQualityCheckService")
override suspend fun fetchProductReview(): ProductAnalysis? = withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
browserStore.state.selectedTab?.let { tab ->
tab.engineState.engineSession?.requestProductAnalysis(
url = tab.content.url,
onResult = {
continuation.resume(it)
},
onException = {
logger.error("Error fetching product review", it)
continuation.resume(null)
},
)
}
}
}
override suspend fun reanalyzeProduct(): AnalysisStatusDto? = withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
browserStore.state.selectedTab?.let { tab ->
tab.engineState.engineSession?.reanalyzeProduct(
url = tab.content.url,
onResult = {
continuation.resume(it.asEnumOrDefault(AnalysisStatusDto.OTHER))
},
onException = {
logger.error("Error starting reanalysis", it)
continuation.resume(null)
},
)
}
}
}
override suspend fun analysisStatus(): AnalysisStatusProgressDto? = withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
browserStore.state.selectedTab?.let { tab ->
tab.engineState.engineSession?.requestAnalysisStatus(
url = tab.content.url,
onResult = {
continuation.resume(
AnalysisStatusProgressDto(
status = it.status.asEnumOrDefault(AnalysisStatusDto.OTHER)!!,
progress = it.progress,
),
)
},
onException = {
logger.error("Error fetching analysis status", it)
continuation.resume(null)
},
)
}
}
}
override suspend fun productRecommendation(shouldRecordAvailableTelemetry: Boolean): ProductRecommendation? =
withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
browserStore.state.selectedTab?.let { tab ->
if (recommendationsCache.containsKey(tab.content.url)) {
continuation.resume(recommendationsCache[tab.content.url])
} else {
tab.engineState.engineSession?.requestProductRecommendations(
url = tab.content.url,
onResult = {
if (it.isEmpty()) {
if (shouldRecordAvailableTelemetry) {
Shopping.surfaceNoAdsAvailable.record()
}
} else {
Shopping.adsExposure.record()
}
// Return the first available recommendation since ui requires only
// one recommendation.
continuation.resume(
it.firstOrNull()?.also { recommendation ->
recommendationsCache[tab.content.url] = recommendation
},
)
},
onException = {
logger.error("Error fetching product recommendation", it)
continuation.resume(null)
},
)
}
}
}
}
override suspend fun reportBackInStock(): ReportBackInStockStatusDto? = withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
browserStore.state.selectedTab?.let { tab ->
tab.engineState.engineSession?.reportBackInStock(
url = tab.content.url,
onResult = {
continuation.resume(it.asEnumOrDefault<ReportBackInStockStatusDto>())
},
onException = {
logger.error("Error reporting product back in stock", it)
continuation.resume(null)
},
)
}
}
}
}
/**
* Enum that represents the status of the product review analysis.
*/
enum class AnalysisStatusDto {
/**
* Analysis is waiting to be picked up.
*/
PENDING,
/**
* Analysis is in progress.
*/
IN_PROGRESS,
/**
* Analysis is completed.
*/
COMPLETED,
/**
* Any other status.
*/
OTHER,
}
/**
* Class that represents the analysis status response of the product review analysis.
*
* @property status Enum indicating the current status of the analysis
* @property progress Number indicating the progress of the analysis
*/
data class AnalysisStatusProgressDto(
val status: AnalysisStatusDto,
val progress: Double,
)
/**
* Enum that represents the status returned from reporting a product is back in stock.
*/
enum class ReportBackInStockStatusDto {
/**
* Report created.
*/
REPORT_CREATED,
/**
* Product is already reported to be back in stock.
*/
ALREADY_REPORTED,
/**
* Product was not actually marked as deleted.
*/
NOT_DELETED,
}

View File

@@ -1,203 +0,0 @@
/* 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.shopping.middleware
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.lib.state.MiddlewareContext
import mozilla.components.lib.state.Store
import org.mozilla.fenix.GleanMetrics.Shopping
import org.mozilla.fenix.GleanMetrics.ShoppingSettings
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction
import org.mozilla.fenix.components.appstate.shopping.ShoppingState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
import org.mozilla.fenix.shopping.store.ReviewQualityCheckMiddleware
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
private const val ACTION_ENABLED = "enabled"
private const val ACTION_DISABLED = "disabled"
/**
* Middleware that captures telemetry events for the review quality check feature.
*
* @param telemetryService The service that handles telemetry events for review checker.
* @param browserStore The [BrowserStore] instance to access the current tab.
* @param appStore The [AppStore] instance to access [ShoppingState].
* @param scope The [CoroutineScope] to use for launching coroutines.
*/
class ReviewQualityCheckTelemetryMiddleware(
private val telemetryService: ReviewQualityCheckTelemetryService,
private val browserStore: BrowserStore,
private val appStore: AppStore,
private val scope: CoroutineScope,
) : ReviewQualityCheckMiddleware {
override fun invoke(
context: MiddlewareContext<ReviewQualityCheckState, ReviewQualityCheckAction>,
next: (ReviewQualityCheckAction) -> Unit,
action: ReviewQualityCheckAction,
) {
next(action)
when (action) {
is ReviewQualityCheckAction.TelemetryAction -> processAction(context.store, action)
else -> {
// no-op
}
}
}
@Suppress("LongMethod")
private fun processAction(
store: Store<ReviewQualityCheckState, ReviewQualityCheckAction>,
action: ReviewQualityCheckAction.TelemetryAction,
) {
when (action) {
is ReviewQualityCheckAction.OptIn -> {
Shopping.surfaceOptInAccepted.record()
ShoppingSettings.userHasOnboarded.set(true)
}
is ReviewQualityCheckAction.OptOut -> {
ShoppingSettings.componentOptedOut.set(true)
}
is ReviewQualityCheckAction.BottomSheetClosed -> {
Shopping.surfaceClosed.record(
Shopping.SurfaceClosedExtra(action.source.sourceName),
)
}
is ReviewQualityCheckAction.BottomSheetDisplayed -> {
Shopping.surfaceDisplayed.record(
Shopping.SurfaceDisplayedExtra(action.view.state),
)
}
is ReviewQualityCheckAction.OpenExplainerLearnMoreLink -> {
Shopping.surfaceReviewQualityExplainerUrlClicked.record()
}
is ReviewQualityCheckAction.OpenOnboardingLearnMoreLink -> {
Shopping.surfaceLearnMoreClicked.record()
}
is ReviewQualityCheckAction.OpenOnboardingPrivacyPolicyLink -> {
Shopping.surfaceShowPrivacyPolicyClicked.record()
}
is ReviewQualityCheckAction.OpenOnboardingTermsLink -> {
Shopping.surfaceShowTermsClicked.record()
}
is ReviewQualityCheckAction.NotNowClicked -> {
Shopping.surfaceNotNowClicked.record()
}
is ReviewQualityCheckAction.ExpandCollapseHighlights -> {
val state = store.state
if (state is ReviewQualityCheckState.OptedIn && state.isHighlightsExpanded) {
Shopping.surfaceShowMoreRecentReviewsClicked.record()
}
}
is ReviewQualityCheckAction.ExpandCollapseSettings -> {
val state = store.state
if (state is ReviewQualityCheckState.OptedIn && state.isSettingsExpanded) {
Shopping.surfaceExpandSettings.record()
}
}
is ReviewQualityCheckAction.NoAnalysisDisplayed -> {
Shopping.surfaceNoReviewReliabilityAvailable.record()
}
is ReviewQualityCheckAction.UpdateProductReview -> {
val state = store.state
if (state.isStaleAnalysis() && !action.restoreAnalysis) {
Shopping.surfaceStaleAnalysisShown.record()
}
}
is ReviewQualityCheckAction.AnalyzeProduct -> {
Shopping.surfaceAnalyzeReviewsNoneAvailableClicked.record()
}
is ReviewQualityCheckAction.ReanalyzeProduct -> {
Shopping.surfaceReanalyzeClicked.record()
}
is ReviewQualityCheckAction.ReportProductBackInStock -> {
Shopping.surfaceReactivatedButtonClicked.record()
}
is ReviewQualityCheckAction.OptOutCompleted -> {
Shopping.surfaceOnboardingDisplayed.record()
}
is ReviewQualityCheckAction.OpenPoweredByLink -> {
Shopping.surfacePoweredByFakespotLinkClicked.record()
}
is ReviewQualityCheckAction.RecommendedProductImpression -> {
browserStore.state.selectedTab?.let { tabSessionState ->
val key = ShoppingState.ProductRecommendationImpressionKey(
tabId = tabSessionState.id,
productUrl = tabSessionState.content.url,
aid = action.productAid,
)
val recordedImpressions =
appStore.state.shoppingState.recordedProductRecommendationImpressions
if (!recordedImpressions.contains(key)) {
Shopping.surfaceAdsImpression.record()
scope.launch {
val result =
telemetryService.recordRecommendedProductImpression(action.productAid)
if (result != null) {
appStore.dispatch(ShoppingAction.ProductRecommendationImpression(key))
}
}
}
}
}
is ReviewQualityCheckAction.RecommendedProductClick -> {
Shopping.surfaceAdsClicked.record()
scope.launch {
telemetryService.recordRecommendedProductClick(action.productAid)
}
}
ReviewQualityCheckAction.ToggleProductRecommendation -> {
val state = store.state
if (state is ReviewQualityCheckState.OptedIn &&
state.productRecommendationsPreference != null
) {
val toggleAction = if (state.productRecommendationsPreference) {
ACTION_ENABLED
} else {
ACTION_DISABLED
}
Shopping.surfaceAdsSettingToggled.record(
Shopping.SurfaceAdsSettingToggledExtra(
action = toggleAction,
),
)
}
}
}
}
private fun ReviewQualityCheckState.isStaleAnalysis(): Boolean =
this is ReviewQualityCheckState.OptedIn &&
this.productReviewState is ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent &&
this.productReviewState.analysisStatus == AnalysisStatus.NeedsAnalysis
}

View File

@@ -1,77 +0,0 @@
/* 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.shopping.middleware
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.support.base.log.logger.Logger
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* Service that handles telemetry events for review checker.
*/
interface ReviewQualityCheckTelemetryService {
/**
* Sends a click attribution event for a given product aid.
*/
suspend fun recordRecommendedProductClick(productAid: String): Unit?
/**
* Sends an impression attribution event for a given product aid.
*/
suspend fun recordRecommendedProductImpression(productAid: String): Unit?
}
/**
* Service that handles the network requests for the review quality check feature.
*
* @param browserStore Reference to the application's [BrowserStore] to access state.
*/
class DefaultReviewQualityCheckTelemetryService(
private val browserStore: BrowserStore,
) : ReviewQualityCheckTelemetryService {
private val logger = Logger(TAG)
override suspend fun recordRecommendedProductClick(productAid: String) =
withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
browserStore.state.selectedTab?.engineState?.engineSession?.sendClickAttributionEvent(
aid = productAid,
onResult = {
continuation.resume(Unit)
},
onException = {
logger.error("Error sending click attribution event", it)
continuation.resume(null)
},
)
}
}
override suspend fun recordRecommendedProductImpression(productAid: String) =
withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
browserStore.state.selectedTab?.engineState?.engineSession?.sendImpressionAttributionEvent(
aid = productAid,
onResult = {
continuation.resume(Unit)
},
onException = {
logger.error("Error sending impression attribution event", it)
continuation.resume(null)
},
)
}
}
companion object {
private const val TAG = "ReviewQualityCheckTelemetryService"
}
}

View File

@@ -1,95 +0,0 @@
/* 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.shopping.middleware
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.ProductVendor
import java.net.URI
import java.net.URISyntaxException
private const val AMAZON_COM = "amazon.com"
private const val BEST_BUY_COM = "bestbuy.com"
private const val WALMART_COM = "walmart.com"
private const val AMAZON_DE = "amazon.de"
private const val AMAZON_FR = "amazon.fr"
private val defaultVendorsList = enumValues<ProductVendor>().toList()
/**
* Service for getting the list of product vendors.
*/
interface ReviewQualityCheckVendorsService {
/**
* Returns the selected tab url.
*/
fun selectedTabUrl(): String?
/**
* Returns the list of product vendors in order.
*/
fun productVendors(): List<ProductVendor>
}
/**
* Default implementation of [ReviewQualityCheckVendorsService] that uses the [BrowserStore] to
* identify the selected tab.
*
* @param browserStore The [BrowserStore] instance to use.
*/
class DefaultReviewQualityCheckVendorsService(
private val browserStore: BrowserStore,
) : ReviewQualityCheckVendorsService {
override fun selectedTabUrl(): String? =
browserStore.state.selectedTab?.content?.url
override fun productVendors(): List<ProductVendor> {
val selectedTabUrl = selectedTabUrl()
return if (selectedTabUrl == null) {
defaultVendorsList
} else {
val host = selectedTabUrl.toJavaUri()?.host
when {
host == null -> defaultVendorsList
host.contains(AMAZON_COM) -> createProductVendorsList(ProductVendor.AMAZON)
host.contains(BEST_BUY_COM) -> createProductVendorsList(ProductVendor.BEST_BUY)
host.contains(WALMART_COM) -> createProductVendorsList(ProductVendor.WALMART)
host.contains(AMAZON_DE) || host.contains(AMAZON_FR) -> listOf(ProductVendor.AMAZON)
else -> defaultVendorsList
}
}
}
/**
* Creates list of product vendors using the firstVendor param as the first item in the list.
*/
private fun createProductVendorsList(firstVendor: ProductVendor): List<ProductVendor> =
listOf(firstVendor) + defaultVendorsList.filterNot { it == firstVendor }
/**
* Convenience function to converts a given string to a [URI] instance. Returns null if the
* string is not a valid URI.
*/
private fun String.toJavaUri(): URI? {
return try {
URI.create(this)
} catch (e: URISyntaxException) {
Logger.error("Unable to create URI with the given string $this", e)
null
} catch (e: IllegalArgumentException) {
Logger.error("Unable to create URI with the given string $this", e)
null
}
}
}
/**
* Returns the first matching product vendor for the selected tab.
*/
fun ReviewQualityCheckVendorsService.productVendor(): ProductVendor =
productVendors().first()

View File

@@ -1,30 +0,0 @@
/* 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.shopping.store
/**
* States the review quality check bottom sheet can be opened in.
*
* @property state Name of the state to be used in review quality check telemetry.
*/
enum class BottomSheetViewState(val state: String) {
FULL_VIEW("full"),
HALF_VIEW("half"),
}
/**
* The source of the bottom sheet dismiss.
*
* @property sourceName Name of the dismiss source to be used in review quality check telemetry.
*/
enum class BottomSheetDismissSource(val sourceName: String) {
BACK_PRESSED("back_pressed"),
SLIDE("sheet_slide"),
HANDLE_CLICKED("handle_clicked"),
CLICK_OUTSIDE("click_outside"),
LINK_OPENED("link_opened"),
OPT_OUT("opt_out"),
NOT_NOW("not_now"),
}

View File

@@ -1,229 +0,0 @@
/* 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.shopping.store
import mozilla.components.lib.state.Action
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.RecommendedProductState
/**
* Actions for review quality check feature.
*/
sealed interface ReviewQualityCheckAction : Action {
/**
* Actions that cause updates to state.
*/
sealed interface UpdateAction : ReviewQualityCheckAction
/**
* Actions related to preferences.
*/
sealed interface PreferencesMiddlewareAction : ReviewQualityCheckAction
/**
* Actions related to navigation events.
*/
sealed interface NavigationMiddlewareAction : ReviewQualityCheckAction
/**
* Actions related to network requests.
*/
sealed interface NetworkAction : ReviewQualityCheckAction
/**
* Actions related to telemetry events.
*/
sealed interface TelemetryAction : ReviewQualityCheckAction
/**
* Triggered when the store is initialized.
*/
object Init : PreferencesMiddlewareAction
/**
* Triggered when the user has opted in to the review quality check feature.
*/
object OptIn : PreferencesMiddlewareAction, TelemetryAction
/**
* Triggered when the user has opted out of the review quality check feature.
*/
object OptOut : PreferencesMiddlewareAction, UpdateAction, TelemetryAction
/**
* Triggered when the user has enabled or disabled product recommendations.
*/
object ToggleProductRecommendation : PreferencesMiddlewareAction, UpdateAction, NetworkAction, TelemetryAction
/**
* Triggered as a result of a [OptIn] or [Init] whe user has opted in for shopping experience.
*
* @property isProductRecommendationsEnabled Reflects the user preference update to display
* recommended product. Null when product recommendations feature is disabled.
* @property productRecommendationsExposure Whether product recommendations exposure is enabled.
* @property productVendor The vendor of the product.
* @property isHighlightsExpanded Whether the highlights card should be expanded.
* @property isInfoExpanded Whether the info card should be expanded.
* @property isSettingsExpanded Whether the settings card should be expanded.
*/
data class OptInCompleted(
val isProductRecommendationsEnabled: Boolean?,
val productRecommendationsExposure: Boolean,
val productVendor: ReviewQualityCheckState.ProductVendor,
val isHighlightsExpanded: Boolean,
val isInfoExpanded: Boolean,
val isSettingsExpanded: Boolean,
) : UpdateAction
/**
* Triggered as a result of [Init] when user has opted out of shopping experience.
*
* @property productVendors List of product vendors in relevant order.
*/
data class OptOutCompleted(
val productVendors: List<ReviewQualityCheckState.ProductVendor>,
) : UpdateAction, TelemetryAction
/**
* Triggered as a result of a [NetworkAction] to update the [ProductReviewState].
*
* @property productReviewState The product review state to update.
* @property restoreAnalysis Signals whether the analysis will be restored right after the update.
*/
data class UpdateProductReview(
val productReviewState: ProductReviewState,
val restoreAnalysis: Boolean,
) : UpdateAction, TelemetryAction
/**
* Triggered as a result of a [NetworkAction] to update the [RecommendedProductState].
*/
data class UpdateRecommendedProduct(
val recommendedProductState: RecommendedProductState,
) : UpdateAction
/**
* Triggered when the user has opted in to the review quality check feature and the UI is opened.
*/
object FetchProductAnalysis : NetworkAction, UpdateAction
/**
* Triggered when the user retries to fetch product analysis after a failure.
*/
object RetryProductAnalysis : NetworkAction, UpdateAction
/**
* Triggered when the user triggers product re-analysis.
*/
object ReanalyzeProduct : NetworkAction, UpdateAction, TelemetryAction
/**
* Triggered when the product was previously known to be in reanalysis
* process when the sheet was closed and the state should be restored.
*/
object RestoreReanalysis : NetworkAction, UpdateAction
/**
* Triggered when the user clicks on the analyze button
*/
object AnalyzeProduct : NetworkAction, UpdateAction, TelemetryAction
/**
* Triggered when the analysis status is updated.
*
* @property progress The progress of the analysis ranging from 0.0-100.0.
*/
data class UpdateAnalysisProgress(val progress: Double) : UpdateAction
/**
* Triggered when the user clicks on the recommended product.
*
* @property productAid The product's aid.
* @property productUrl The product's link to open.
*/
data class RecommendedProductClick(
val productAid: String,
val productUrl: String,
) : NavigationMiddlewareAction, TelemetryAction
/**
* Triggered when the user views the recommended product.
*
* @property productAid The product's aid.
*/
data class RecommendedProductImpression(
val productAid: String,
) : TelemetryAction
/**
* Triggered when the user clicks on learn more link on the explainer card.
*/
object OpenExplainerLearnMoreLink : NavigationMiddlewareAction, TelemetryAction
/**
* Triggered when the user clicks on the "Powered by" link in the footer.
*/
object OpenPoweredByLink : NavigationMiddlewareAction, TelemetryAction
/**
* Triggered when the user clicks on learn more link on the opt in card.
*/
object OpenOnboardingLearnMoreLink : NavigationMiddlewareAction, TelemetryAction
/**
* Triggered when the user clicks on terms and conditions link on the opt in card.
*/
object OpenOnboardingTermsLink : NavigationMiddlewareAction, TelemetryAction
/**
* Triggered when the user clicks on privacy policy link on the opt in card.
*/
object OpenOnboardingPrivacyPolicyLink : NavigationMiddlewareAction, TelemetryAction
/**
* Triggered when the bottom sheet is closed.
*
* @property source The source of dismissal.
*/
data class BottomSheetClosed(val source: BottomSheetDismissSource) : TelemetryAction
/**
* Triggered when the bottom sheet is opened.
*
* @property view The state of the bottom sheet when opened.
*/
data class BottomSheetDisplayed(val view: BottomSheetViewState) : TelemetryAction
/**
* Triggered when the user clicks on the "Not now" button from the contextual onboarding card.
*/
object NotNowClicked : TelemetryAction
/**
* Triggered when the user expands the recent reviews card.
*/
object ExpandCollapseHighlights : TelemetryAction, UpdateAction, PreferencesMiddlewareAction
/**
* Triggered when the user expands or collapses the settings card.
*/
object ExpandCollapseSettings : TelemetryAction, UpdateAction, PreferencesMiddlewareAction
/**
* Triggered when the user expands or collapses the info card.
*/
object ExpandCollapseInfo : UpdateAction, PreferencesMiddlewareAction
/**
* Triggered when the No analysis card is displayed to the user.
*/
object NoAnalysisDisplayed : TelemetryAction
/**
* Triggered when the user reports a product is back in stock.
*/
object ReportProductBackInStock : NetworkAction, UpdateAction, TelemetryAction
}

View File

@@ -1,12 +0,0 @@
/* 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.shopping.store
import mozilla.components.lib.state.Middleware
/**
* Middleware typealias for review quality check.
*/
typealias ReviewQualityCheckMiddleware = Middleware<ReviewQualityCheckState, ReviewQualityCheckAction>

View File

@@ -1,287 +0,0 @@
/* 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.shopping.store
import androidx.compose.runtime.Immutable
import mozilla.components.lib.state.State
import java.text.NumberFormat
private const val NUMBER_OF_HIGHLIGHTS_FOR_COMPACT_MODE = 2
/**
* UI state of the review quality check feature.
*/
sealed interface ReviewQualityCheckState : State {
/**
* The initial state of the feature, it's also the default state set in the store.
*/
object Initial : ReviewQualityCheckState
/**
* The state when the user has not opted in for the feature.
*
* @property productVendors List of vendors to be displayed in order in the onboarding UI.
*/
data class NotOptedIn(
val productVendors: List<ProductVendor> = enumValues<ProductVendor>().toList(),
) : ReviewQualityCheckState
/**
* Supported product retailers.
*/
enum class ProductVendor {
AMAZON, BEST_BUY, WALMART,
}
/**
* The state when the user has opted in for the feature.
*
* @property productReviewState The state of the product the user is browsing.
* @property productRecommendationsPreference User preference whether to show product
* recommendations. True if product recommendations should be shown. Null indicates that product
* recommendations are disabled.
* @property productRecommendationsExposure Whether product recommendations exposure is enabled.
* @property productVendor The vendor of the product.
* @property isSettingsExpanded Whether or not the settings card is expanded.
* @property isInfoExpanded Whether or not the info card is expanded.
* @property isHighlightsExpanded Whether or not the highlights card is expanded.
*/
data class OptedIn(
val productReviewState: ProductReviewState = ProductReviewState.Loading,
val productRecommendationsPreference: Boolean?,
val productRecommendationsExposure: Boolean,
val productVendor: ProductVendor,
val isSettingsExpanded: Boolean = false,
val isInfoExpanded: Boolean = false,
val isHighlightsExpanded: Boolean = false,
) : ReviewQualityCheckState {
/**
* The state of the product the user is browsing.
*/
sealed interface ProductReviewState {
/**
* Denotes content is loading.
*/
object Loading : ProductReviewState
/**
* Denotes an error has occurred.
*/
sealed interface Error : ProductReviewState {
/**
* Denotes a network error has occurred.
*/
object NetworkError : Error
/**
* Denotes a product is not supported.
*/
object UnsupportedProductTypeError : Error
/**
* Denotes a product does not have enough reviews to be analyzed.
*/
object NotEnoughReviews : Error
/**
* Denotes a generic error has occurred.
*/
object GenericError : Error
/**
* Denotes a product is not available.
*/
object ProductNotAvailable : Error
/**
* Denotes current user reported a product is back in stock.
*/
object ThanksForReporting : Error
/**
* Denotes another user has already reported the product is back in stock.
*/
object ProductAlreadyReported : Error
}
/**
* Denotes no analysis is present for the product the user is browsing.
*
* @property progress The [Progress] of the analysis, ranges from 0-100.
* Default value is -1, which means analysis is not in progress.
*/
data class NoAnalysisPresent(
val progress: Progress = Progress(-1f),
) : ProductReviewState {
/**
* Whether or not the progress bar is visible.
*/
val isProgressBarVisible: Boolean = progress.value != -1f
}
/**
* Denotes the state where analysis of the product is fetched and present.
*
* @property productId The id of the product, e.g ASIN, SKU.
* @property reviewGrade The review grade of the product.
* @property analysisStatus The status of the product analysis.
* @property adjustedRating The adjusted rating taking review quality into consideration.
* @property productUrl The url of the product the user is browsing.
* @property highlightsInfo Optional highlights based on recent reviews of the product.
* @property recommendedProductState The state of the recommended product.
*/
data class AnalysisPresent(
val productId: String,
val reviewGrade: Grade?,
val analysisStatus: AnalysisStatus,
val adjustedRating: Float?,
val productUrl: String,
val highlightsInfo: HighlightsInfo?,
val recommendedProductState: RecommendedProductState = RecommendedProductState.Initial,
) : ProductReviewState {
init {
require(!(highlightsInfo == null && reviewGrade == null && adjustedRating == null)) {
"AnalysisPresent state should only be created when at least one of " +
"reviewGrade, adjustedRating or highlights is not null"
}
}
/**
* Container for highlights and it's derived properties
*
* @property highlights highlights based on recent reviews of the product.
*/
@Immutable
data class HighlightsInfo(
val highlights: Map<HighlightType, List<String>>,
) {
/**
* Highlights to display in compact mode that contains first 2 highlights of the
* first highlight type.
*/
val highlightsForCompactMode: Map<HighlightType, List<String>> =
highlights.entries.first().let { entry ->
mapOf(
entry.key to entry.value.take(NUMBER_OF_HIGHLIGHTS_FOR_COMPACT_MODE),
)
}
val showMoreButtonVisible: Boolean = highlights != highlightsForCompactMode
val highlightsFadeVisible: Boolean =
showMoreButtonVisible && highlightsForCompactMode.entries.first().value.size > 1
}
/**
* The state of the product analysis.
*/
sealed interface AnalysisStatus {
/**
* Denotes reanalysis is in progress.
*
* @property progress The [Progress] of the analysis, ranges from 0-100.
*/
data class Reanalyzing(val progress: Progress) : AnalysisStatus
/**
* Denotes a product needs analysis.
*/
object NeedsAnalysis : AnalysisStatus
/**
* Denotes a product analysis is up to date.
*/
object UpToDate : AnalysisStatus
}
}
/**
* Progress of the analysis, ranges from 0-100.
*
* @property value The value of the progress.
*/
data class Progress(val value: Float) {
/**
* Normalized progress, ranges from 0-1.
*/
val normalizedProgress: Float = value / 100f
/**
* Percentage formatted progress ranging from 0-100%.
*/
val formattedProgress: String = FORMATTER.format(normalizedProgress)
companion object {
private val FORMATTER = NumberFormat.getPercentInstance().apply {
maximumFractionDigits = 0
}
}
}
}
}
/**
* Review Grade of the product - A being the best and F being the worst. There is no grade E.
*/
enum class Grade {
A, B, C, D, F
}
/**
* Factors for which highlights are available based on recent reviews of the product.
*/
enum class HighlightType {
QUALITY, PRICE, SHIPPING, PACKAGING_AND_APPEARANCE, COMPETITIVENESS
}
/**
* The state of the recommended product.
*/
sealed interface RecommendedProductState {
/**
* The initial state of the recommended product.
*/
object Initial : RecommendedProductState
/**
* The state when the recommended product is available.
*
* @property aid The unique identifier of the product.
* @property name The name of the product.
* @property productUrl The url of the product.
* @property imageUrl The url of the image of the product.
* @property formattedPrice The formatted price of the product.
* @property reviewGrade The review grade of the product.
* @property adjustedRating The adjusted rating of the product.
* @property isSponsored True if the product is sponsored.
* @property analysisUrl The url of the analysis of the product.
*/
data class Product(
val aid: String,
val name: String,
val productUrl: String,
val imageUrl: String,
val formattedPrice: String,
val reviewGrade: Grade,
val adjustedRating: Float,
val isSponsored: Boolean,
val analysisUrl: String,
) : RecommendedProductState
}
/**
* Returns [ReviewQualityCheckState] applying the given [transform] function if the current
* state is [OptedIn].
*/
fun mapIfOptedIn(transform: (OptedIn) -> ReviewQualityCheckState): ReviewQualityCheckState =
if (this is OptedIn) {
transform(this)
} else {
this
}
}

View File

@@ -1,213 +0,0 @@
/* 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.shopping.store
import mozilla.components.lib.state.Store
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
/**
* Store for review quality check feature.
*
* @param initialState The initial state of the store.
* @param middleware The list of middlewares to use.
*/
class ReviewQualityCheckStore(
initialState: ReviewQualityCheckState = ReviewQualityCheckState.Initial,
middleware: List<ReviewQualityCheckMiddleware>,
) : Store<ReviewQualityCheckState, ReviewQualityCheckAction>(
initialState = initialState,
middleware = middleware,
reducer = ::reducer,
) {
init {
dispatch(ReviewQualityCheckAction.Init)
}
}
private fun reducer(
state: ReviewQualityCheckState,
action: ReviewQualityCheckAction,
): ReviewQualityCheckState {
if (action is ReviewQualityCheckAction.UpdateAction) {
return mapStateForUpdateAction(state, action)
}
return state
}
@Suppress("LongMethod")
private fun mapStateForUpdateAction(
state: ReviewQualityCheckState,
action: ReviewQualityCheckAction.UpdateAction,
): ReviewQualityCheckState {
return when (action) {
is ReviewQualityCheckAction.OptInCompleted -> {
if (state is ReviewQualityCheckState.OptedIn) {
state.copy(
productRecommendationsPreference = action.isProductRecommendationsEnabled,
productRecommendationsExposure = action.productRecommendationsExposure,
isHighlightsExpanded = action.isHighlightsExpanded,
isInfoExpanded = action.isInfoExpanded,
isSettingsExpanded = action.isSettingsExpanded,
)
} else {
ReviewQualityCheckState.OptedIn(
productRecommendationsPreference = action.isProductRecommendationsEnabled,
productRecommendationsExposure = action.productRecommendationsExposure,
productVendor = action.productVendor,
isHighlightsExpanded = action.isHighlightsExpanded,
isInfoExpanded = action.isInfoExpanded,
isSettingsExpanded = action.isSettingsExpanded,
)
}
}
is ReviewQualityCheckAction.OptOutCompleted -> {
ReviewQualityCheckState.NotOptedIn(action.productVendors)
}
ReviewQualityCheckAction.OptOut -> {
ReviewQualityCheckState.NotOptedIn()
}
ReviewQualityCheckAction.ExpandCollapseSettings -> {
state.mapIfOptedIn {
it.copy(isSettingsExpanded = !it.isSettingsExpanded)
}
}
ReviewQualityCheckAction.ExpandCollapseInfo -> {
state.mapIfOptedIn {
it.copy(isInfoExpanded = !it.isInfoExpanded)
}
}
ReviewQualityCheckAction.ExpandCollapseHighlights -> {
state.mapIfOptedIn {
it.copy(isHighlightsExpanded = !it.isHighlightsExpanded)
}
}
ReviewQualityCheckAction.ToggleProductRecommendation -> {
if (state is ReviewQualityCheckState.OptedIn && state.productRecommendationsPreference != null) {
if (state.productReviewState is ProductReviewState.AnalysisPresent &&
state.productRecommendationsPreference
) {
// Removes any existing product recommendation from UI
state.copy(
productRecommendationsPreference = false,
productReviewState = state.productReviewState.copy(
recommendedProductState = ReviewQualityCheckState.RecommendedProductState.Initial,
),
)
} else {
state.copy(productRecommendationsPreference = !state.productRecommendationsPreference)
}
} else {
state
}
}
is ReviewQualityCheckAction.UpdateProductReview -> {
state.mapIfOptedIn {
it.copy(productReviewState = action.productReviewState)
}
}
ReviewQualityCheckAction.FetchProductAnalysis, ReviewQualityCheckAction.RetryProductAnalysis -> {
state.mapIfOptedIn {
it.copy(productReviewState = ProductReviewState.Loading)
}
}
ReviewQualityCheckAction.ReanalyzeProduct,
ReviewQualityCheckAction.AnalyzeProduct,
ReviewQualityCheckAction.RestoreReanalysis,
-> {
state.mapIfOptedIn {
when (it.productReviewState) {
is ProductReviewState.AnalysisPresent -> {
val productReviewState =
it.productReviewState.copy(
analysisStatus = AnalysisStatus.Reanalyzing(
ProductReviewState.Progress(0f),
),
)
it.copy(productReviewState = productReviewState)
}
is ProductReviewState.NoAnalysisPresent -> {
it.copy(
productReviewState = it.productReviewState.copy(
progress = ProductReviewState.Progress(0f),
),
)
}
else -> {
it
}
}
}
}
is ReviewQualityCheckAction.UpdateRecommendedProduct -> {
state.mapIfOptedIn {
if (it.productReviewState is ProductReviewState.AnalysisPresent &&
it.productRecommendationsPreference == true
) {
it.copy(
productReviewState = it.productReviewState.copy(
recommendedProductState = action.recommendedProductState,
),
)
} else {
it
}
}
}
is ReviewQualityCheckAction.UpdateAnalysisProgress -> {
state.mapIfOptedIn {
when (it.productReviewState) {
is ProductReviewState.NoAnalysisPresent -> {
it.copy(
productReviewState = it.productReviewState.copy(
progress = ProductReviewState.Progress(action.progress.toFloat()),
),
)
}
is ProductReviewState.AnalysisPresent -> {
it.copy(
productReviewState = it.productReviewState.copy(
analysisStatus = AnalysisStatus.Reanalyzing(
ProductReviewState.Progress(action.progress.toFloat()),
),
),
)
}
else -> {
it
}
}
}
}
ReviewQualityCheckAction.ReportProductBackInStock -> {
state.mapIfOptedIn {
if (it.productReviewState is ProductReviewState.Error.ProductNotAvailable) {
it.copy(
productReviewState = ProductReviewState.Error.ThanksForReporting,
)
} else {
it
}
}
}
}
}

View File

@@ -1,237 +0,0 @@
/* 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.shopping.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.annotation.LightDarkPreview
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.InfoCardContainer
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.NoAnalysisPresent
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.Progress
import org.mozilla.fenix.theme.FirefoxTheme
/**
* No analysis UI for review quality check content.
*
* @param noAnalysisPresent The state of the analysis progress.
* @param productRecommendationsEnabled The current state of the product recommendations toggle.
* @param productVendor The vendor of the product.
* @param isSettingsExpanded Whether or not the settings card is expanded.
* @param isInfoExpanded Whether or not the info card is expanded.
* @param onAnalyzeClick Invoked when the user clicks on the check review button.
* @param onReviewGradeLearnMoreClick Invoked when the user clicks to learn more about review grades.
* @param onOptOutClick Invoked when the user opts out of the review quality check feature.
* @param onProductRecommendationsEnabledStateChange Invoked when the user changes the product
* recommendations toggle state.
* @param onSettingsExpandToggleClick Invoked when the user expands or collapses the settings card.
* @param onInfoExpandToggleClick Invoked when the user expands or collapses the info card.
* @param onFooterLinkClick Invoked when the user clicks on the footer link.
* @param modifier Modifier to be applied to the composable.
*/
@Suppress("LongParameterList")
@Composable
fun NoAnalysis(
noAnalysisPresent: NoAnalysisPresent,
productRecommendationsEnabled: Boolean?,
productVendor: ReviewQualityCheckState.ProductVendor,
isSettingsExpanded: Boolean,
isInfoExpanded: Boolean,
onAnalyzeClick: () -> Unit,
onReviewGradeLearnMoreClick: () -> Unit,
onOptOutClick: () -> Unit,
onProductRecommendationsEnabledStateChange: (Boolean) -> Unit,
onSettingsExpandToggleClick: () -> Unit,
onInfoExpandToggleClick: () -> Unit,
onFooterLinkClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ReviewQualityNoAnalysisCard(noAnalysisPresent, onAnalyzeClick)
ReviewQualityInfoCard(
productVendor = productVendor,
isExpanded = isInfoExpanded,
onLearnMoreClick = onReviewGradeLearnMoreClick,
onExpandToggleClick = onInfoExpandToggleClick,
)
ReviewQualityCheckSettingsCard(
productRecommendationsEnabled = productRecommendationsEnabled,
isExpanded = isSettingsExpanded,
onProductRecommendationsEnabledStateChange = onProductRecommendationsEnabledStateChange,
onTurnOffReviewQualityCheckClick = onOptOutClick,
onExpandToggleClick = onSettingsExpandToggleClick,
modifier = Modifier.fillMaxWidth(),
)
ReviewQualityCheckFooter(
onLinkClick = onFooterLinkClick,
)
}
}
@Composable
private fun ReviewQualityNoAnalysisCard(
noAnalysisPresent: NoAnalysisPresent,
onAnalyzeClick: () -> Unit,
) {
InfoCardContainer(
modifier = Modifier.fillMaxWidth(),
) {
Image(
painter = painterResource(id = R.drawable.shopping_no_analysis),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(all = 10.dp),
)
Spacer(Modifier.height(8.dp))
if (noAnalysisPresent.isProgressBarVisible) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
DeterminateProgressIndicator(
progress = noAnalysisPresent.progress.normalizedProgress,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(
id = R.string.review_quality_check_analysis_in_progress_warning_title_2,
noAnalysisPresent.progress.formattedProgress,
),
style = FirefoxTheme.typography.headline8,
color = FirefoxTheme.colors.textPrimary,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.review_quality_check_analysis_in_progress_warning_body),
style = FirefoxTheme.typography.body2,
color = FirefoxTheme.colors.textSecondary,
modifier = Modifier.fillMaxWidth(),
)
} else {
Text(
text = stringResource(id = R.string.review_quality_check_no_analysis_title),
style = FirefoxTheme.typography.headline8,
color = FirefoxTheme.colors.textPrimary,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.review_quality_check_no_analysis_body),
style = FirefoxTheme.typography.body2,
color = FirefoxTheme.colors.textSecondary,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
TextButton(
modifier = Modifier
.fillMaxWidth()
.background(
color = FirefoxTheme.colors.actionPrimary,
shape = RoundedCornerShape(4.dp),
),
onClick = onAnalyzeClick,
) {
Text(
text = stringResource(id = R.string.review_quality_check_no_analysis_link),
color = FirefoxTheme.colors.textOnColorPrimary,
style = FirefoxTheme.typography.headline8,
)
}
}
}
}
private class NoAnalysisPreviewModelParameterProvider :
PreviewParameterProvider<NoAnalysisPresent> {
override val values: Sequence<NoAnalysisPresent>
get() = sequenceOf(
NoAnalysisPresent(),
NoAnalysisPresent(Progress(50f)),
NoAnalysisPresent(Progress(100f)),
)
}
@Composable
@LightDarkPreview
private fun NoAnalysisPreview(
@PreviewParameter(NoAnalysisPreviewModelParameterProvider::class) noAnalysisPresent: NoAnalysisPresent,
) {
FirefoxTheme {
Box(
modifier = Modifier
.fillMaxWidth()
.background(color = FirefoxTheme.colors.layer1)
.padding(all = 16.dp),
) {
var isAnalyzing by remember { mutableStateOf(false) }
var productRecommendationsEnabled by remember { mutableStateOf(false) }
var isSettingsExpanded by remember { mutableStateOf(false) }
var isInfoExpanded by remember { mutableStateOf(false) }
NoAnalysis(
noAnalysisPresent = noAnalysisPresent,
onAnalyzeClick = { isAnalyzing = !isAnalyzing },
productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
productRecommendationsEnabled = productRecommendationsEnabled,
isSettingsExpanded = isSettingsExpanded,
isInfoExpanded = isInfoExpanded,
onReviewGradeLearnMoreClick = {},
onOptOutClick = {},
onProductRecommendationsEnabledStateChange = { productRecommendationsEnabled = it },
onSettingsExpandToggleClick = { isSettingsExpanded = !isSettingsExpanded },
onInfoExpandToggleClick = { isInfoExpanded = !isInfoExpanded },
onFooterLinkClick = {},
modifier = Modifier.fillMaxWidth(),
)
}
}
}

View File

@@ -1,761 +0,0 @@
/* 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.shopping.ui
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.layout
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CollectionInfo
import androidx.compose.ui.semantics.CollectionItemInfo
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.collectionInfo
import androidx.compose.ui.semantics.collectionItemInfo
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.Divider
import mozilla.components.compose.base.annotation.LightDarkPreview
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.Image
import org.mozilla.fenix.compose.InfoCard
import org.mozilla.fenix.compose.InfoCardButtonText
import org.mozilla.fenix.compose.InfoCardContainer
import org.mozilla.fenix.compose.InfoType
import org.mozilla.fenix.compose.button.SecondaryButton
import org.mozilla.fenix.compose.ext.onShown
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.HighlightType
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.HighlightsInfo
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.RecommendedProductState
import org.mozilla.fenix.shopping.ui.ext.headingResource
import org.mozilla.fenix.theme.FirefoxTheme
private val combinedParentHorizontalPadding = 32.dp
private val productRecommendationImageSize = 60.dp
private const val PRODUCT_RECOMMENDATION_SETTLE_TIME_MS = 1500
private const val PRODUCT_RECOMMENDATION_IMPRESSION_THRESHOLD = 0.5f
/**
* UI for review quality check content displaying product analysis.
*
* @param productRecommendationsEnabled The current state of the product recommendations toggle.
* @param productAnalysis The product analysis to display.
* @param productVendor The vendor of the product.
* @param isSettingsExpanded Whether or not the settings card is expanded.
* @param isInfoExpanded Whether or not the info card is expanded.
* @param isHighlightsExpanded Whether or not the highlights card is expanded.
* @param onOptOutClick Invoked when the user opts out of the review quality check feature.
* @param onReanalyzeClick Invoked when the user clicks to re-analyze a product.
* @param onProductRecommendationsEnabledStateChange Invoked when the user changes the product
* recommendations toggle state.
* @param onReviewGradeLearnMoreClick Invoked when the user clicks to learn more about review grades.
* @param onFooterLinkClick Invoked when the user clicks on the footer link.
* @param onHighlightsExpandToggleClick Invoked when the user clicks to show more recent reviews.
* @param onSettingsExpandToggleClick Invoked when the user expands or collapses the settings card.
* @param onInfoExpandToggleClick Invoked when the user expands or collapses the info card.
* @param onRecommendedProductClick Invoked when the user clicks on the product recommendation.
* @param onRecommendedProductImpression Invoked when the user has seen the product recommendation.
* @param modifier The modifier to be applied to the Composable.
*/
@Composable
@Suppress("LongParameterList")
fun ProductAnalysis(
productRecommendationsEnabled: Boolean?,
productAnalysis: AnalysisPresent,
productVendor: ReviewQualityCheckState.ProductVendor,
isSettingsExpanded: Boolean,
isInfoExpanded: Boolean,
isHighlightsExpanded: Boolean,
onOptOutClick: () -> Unit,
onReanalyzeClick: () -> Unit,
onProductRecommendationsEnabledStateChange: (Boolean) -> Unit,
onReviewGradeLearnMoreClick: () -> Unit,
onFooterLinkClick: () -> Unit,
onHighlightsExpandToggleClick: () -> Unit,
onSettingsExpandToggleClick: () -> Unit,
onInfoExpandToggleClick: () -> Unit,
onRecommendedProductClick: (aid: String, url: String) -> Unit,
onRecommendedProductImpression: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
when (val analysisStatus = productAnalysis.analysisStatus) {
is AnalysisStatus.NeedsAnalysis -> {
ReanalyzeCard(onReanalyzeClick = onReanalyzeClick)
}
is AnalysisStatus.Reanalyzing -> {
ReanalysisInProgress(analysisStatus)
}
is AnalysisStatus.UpToDate -> {
// no-op
}
}
if (productAnalysis.reviewGrade != null) {
ReviewGradeCard(
reviewGrade = productAnalysis.reviewGrade,
modifier = Modifier.fillMaxWidth(),
)
}
if (productAnalysis.adjustedRating != null) {
AdjustedProductRatingCard(
rating = productAnalysis.adjustedRating,
modifier = Modifier.fillMaxWidth(),
)
}
if (productAnalysis.highlightsInfo != null) {
HighlightsCard(
highlightsInfo = productAnalysis.highlightsInfo,
onHighlightsExpandToggleClick = onHighlightsExpandToggleClick,
isExpanded = isHighlightsExpanded,
modifier = Modifier.fillMaxWidth(),
)
}
ReviewQualityInfoCard(
productVendor = productVendor,
isExpanded = isInfoExpanded,
modifier = Modifier.fillMaxWidth(),
onExpandToggleClick = onInfoExpandToggleClick,
onLearnMoreClick = onReviewGradeLearnMoreClick,
)
if (productAnalysis.recommendedProductState is RecommendedProductState.Product) {
ProductRecommendation(
product = productAnalysis.recommendedProductState,
onClick = onRecommendedProductClick,
onImpression = onRecommendedProductImpression,
)
}
ReviewQualityCheckSettingsCard(
productRecommendationsEnabled = productRecommendationsEnabled,
isExpanded = isSettingsExpanded,
onProductRecommendationsEnabledStateChange = onProductRecommendationsEnabledStateChange,
onTurnOffReviewQualityCheckClick = onOptOutClick,
onExpandToggleClick = onSettingsExpandToggleClick,
modifier = Modifier.fillMaxWidth(),
)
ReviewQualityCheckFooter(
onLinkClick = onFooterLinkClick,
)
}
}
@Composable
private fun ReanalyzeCard(
onReanalyzeClick: () -> Unit,
) {
InfoCard(
title = stringResource(R.string.review_quality_check_outdated_analysis_warning_title),
type = InfoType.InfoPlain,
modifier = Modifier.fillMaxWidth(),
buttonText = InfoCardButtonText(
text = stringResource(R.string.review_quality_check_outdated_analysis_warning_action),
onClick = onReanalyzeClick,
),
)
}
@Composable
private fun ReanalysisInProgress(reanalyzing: AnalysisStatus.Reanalyzing) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(vertical = 8.dp),
) {
DeterminateProgressIndicator(
progress = reanalyzing.progress.normalizedProgress,
modifier = Modifier.size(24.dp),
)
Text(
text = stringResource(
id = R.string.review_quality_check_analysis_in_progress_warning_title_2,
reanalyzing.progress.formattedProgress,
),
style = FirefoxTheme.typography.subtitle1,
color = FirefoxTheme.colors.textPrimary,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun ReviewGradeCard(
reviewGrade: ReviewQualityCheckState.Grade,
modifier: Modifier = Modifier,
) {
InfoCardContainer(modifier = modifier.semantics(mergeDescendants = true) { heading() }) {
Text(
text = stringResource(R.string.review_quality_check_grade_title),
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.headline8,
)
Spacer(modifier = Modifier.height(8.dp))
ReviewGradeExpanded(grade = reviewGrade)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun AdjustedProductRatingCard(
rating: Float,
modifier: Modifier = Modifier,
) {
InfoCardContainer(modifier = modifier.semantics(mergeDescendants = true) { heading() }) {
FlowRow(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.review_quality_check_adjusted_rating_title),
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.headline8,
modifier = Modifier.padding(
end = 16.dp,
bottom = 8.dp,
),
)
StarRating(
value = rating,
modifier = Modifier.padding(bottom = 8.dp),
)
}
Text(
text = stringResource(R.string.review_quality_check_adjusted_rating_description_2),
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.caption,
)
}
}
@Suppress("LongMethod")
@Composable
private fun HighlightsCard(
highlightsInfo: HighlightsInfo,
isExpanded: Boolean,
onHighlightsExpandToggleClick: () -> Unit,
modifier: Modifier = Modifier,
) {
InfoCardContainer(modifier = modifier) {
val highlightsToDisplay = remember(isExpanded, highlightsInfo.highlights) {
if (isExpanded) {
highlightsInfo.highlights
} else {
highlightsInfo.highlightsForCompactMode
}
}
val titleContentDescription =
headingResource(id = R.string.review_quality_check_highlights_title)
Text(
text = stringResource(R.string.review_quality_check_highlights_title),
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.headline8,
modifier = Modifier.semantics {
heading()
contentDescription = titleContentDescription
},
)
Spacer(modifier = Modifier.height(16.dp))
Box(
contentAlignment = Alignment.BottomCenter,
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.animateContentSize(animationSpec = spring()),
) {
highlightsToDisplay.onEachIndexed { indexHighlightTitle, highlight ->
Box(
modifier = Modifier.semantics {
collectionInfo = CollectionInfo(rowCount = highlightsToDisplay.size, columnCount = 1)
},
) {
HighlightTitle(
highlightType = highlight.key,
modifier = Modifier.semantics {
collectionItemInfo = CollectionItemInfo(
rowIndex = indexHighlightTitle,
rowSpan = 1,
columnIndex = 1,
columnSpan = 1,
)
},
)
}
Spacer(modifier = Modifier.height(8.dp))
Column(
modifier = Modifier.semantics {
collectionInfo =
CollectionInfo(rowCount = highlight.value.size, columnCount = 1)
},
) {
highlight.value.onEachIndexed { index, text ->
HighlightText(
text = text,
modifier = Modifier.semantics {
collectionItemInfo = CollectionItemInfo(
rowIndex = index,
rowSpan = 1,
columnIndex = 1,
columnSpan = 1,
)
},
)
Spacer(modifier = Modifier.height(4.dp))
}
if (highlightsToDisplay.entries.last().key != highlight.key) {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
Crossfade(
targetState = isExpanded,
label = "HighlightsCard-Crossfade",
) { expanded ->
if (expanded.not() && highlightsInfo.highlightsFadeVisible) {
Spacer(
modifier = Modifier
.height(32.dp)
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
FirefoxTheme.colors.layer2.copy(alpha = 0f),
FirefoxTheme.colors.layer2,
),
),
),
)
}
}
}
if (highlightsInfo.showMoreButtonVisible) {
Spacer(modifier = Modifier.height(8.dp))
Divider(modifier = Modifier.extendWidthToParentBorder())
Spacer(modifier = Modifier.height(8.dp))
SecondaryButton(
text = if (isExpanded) {
stringResource(R.string.review_quality_check_highlights_show_less)
} else {
stringResource(R.string.review_quality_check_highlights_show_more)
},
onClick = onHighlightsExpandToggleClick,
)
}
}
}
@Composable
private fun HighlightText(
text: String,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
Spacer(modifier = Modifier.width(32.dp))
Text(
text = stringResource(id = R.string.surrounded_with_quotes, text),
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.body2,
)
}
}
@Composable
private fun HighlightTitle(
highlightType: HighlightType,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
val highlight = remember(highlightType) { highlightType.toHighlight() }
Icon(
painter = painterResource(id = highlight.iconResourceId),
tint = FirefoxTheme.colors.iconPrimary,
contentDescription = null,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(id = highlight.titleResourceId),
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.headline8,
)
}
}
private fun Modifier.extendWidthToParentBorder(): Modifier =
this.layout { measurable, constraints ->
val placeable = measurable.measure(
constraints.copy(
maxWidth = constraints.maxWidth + combinedParentHorizontalPadding.roundToPx(),
),
)
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
private fun HighlightType.toHighlight() =
when (this) {
HighlightType.QUALITY -> Highlight.QUALITY
HighlightType.PRICE -> Highlight.PRICE
HighlightType.SHIPPING -> Highlight.SHIPPING
HighlightType.PACKAGING_AND_APPEARANCE -> Highlight.PACKAGING_AND_APPEARANCE
HighlightType.COMPETITIVENESS -> Highlight.COMPETITIVENESS
}
private enum class Highlight(
val titleResourceId: Int,
val iconResourceId: Int,
) {
QUALITY(
titleResourceId = R.string.review_quality_check_highlights_type_quality,
iconResourceId = R.drawable.mozac_ic_quality_24,
),
PRICE(
titleResourceId = R.string.review_quality_check_highlights_type_price,
iconResourceId = R.drawable.mozac_ic_price_24,
),
SHIPPING(
titleResourceId = R.string.review_quality_check_highlights_type_shipping,
iconResourceId = R.drawable.mozac_ic_shipping_24,
),
PACKAGING_AND_APPEARANCE(
titleResourceId = R.string.review_quality_check_highlights_type_packaging_appearance,
iconResourceId = R.drawable.mozac_ic_packaging_24,
),
COMPETITIVENESS(
titleResourceId = R.string.review_quality_check_highlights_type_competitiveness,
iconResourceId = R.drawable.mozac_ic_competitiveness_24,
),
}
@Suppress("LongMethod")
@Composable
private fun ProductRecommendation(
product: RecommendedProductState.Product,
onClick: (String, String) -> Unit,
onImpression: (String) -> Unit,
) {
val titleContentDescription = headingResource(id = R.string.review_quality_check_ad_title)
val interactionSource = remember { MutableInteractionSource() }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
InfoCardContainer(
modifier = Modifier
.fillMaxWidth()
.onShown(
threshold = PRODUCT_RECOMMENDATION_IMPRESSION_THRESHOLD,
settleTime = PRODUCT_RECOMMENDATION_SETTLE_TIME_MS,
onVisible = { onImpression(product.aid) },
),
) {
Column(
modifier = Modifier
.fillMaxWidth(1f)
.clearAndSetSemantics {
heading()
contentDescription = titleContentDescription
}
.clickable(interactionSource = interactionSource, indication = null) {
onClick(product.aid, product.productUrl)
},
) {
Text(
text = stringResource(R.string.review_quality_check_ad_title),
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.headline8,
)
Spacer(modifier = Modifier.height(8.dp))
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.clickable { onClick(product.aid, product.productUrl) },
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Image(
url = product.imageUrl,
modifier = Modifier.size(productRecommendationImageSize),
targetSize = productRecommendationImageSize,
placeholder = { ImagePlaceholder() },
fallback = { ImagePlaceholder() },
)
Text(
text = product.name,
modifier = Modifier.weight(1.0f),
color = FirefoxTheme.colors.textAccent,
textDecoration = TextDecoration.Underline,
style = FirefoxTheme.typography.body2,
)
ReviewGradeCompact(grade = product.reviewGrade)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = product.formattedPrice,
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.headline8,
)
StarRating(value = product.adjustedRating)
}
}
}
Text(
text = stringResource(
id = R.string.review_quality_check_ad_caption,
stringResource(id = R.string.shopping_product_name),
),
color = FirefoxTheme.colors.textSecondary,
style = FirefoxTheme.typography.body2,
)
}
}
@Composable
private fun ImagePlaceholder() {
Box(
modifier = Modifier
.size(productRecommendationImageSize)
.background(
color = FirefoxTheme.colors.layer3,
shape = RoundedCornerShape(8.dp),
),
) {
Image(
painter = painterResource(id = R.drawable.ic_file_type_image),
contentDescription = null,
modifier = Modifier
.align(Alignment.Center),
)
}
}
private class ProductAnalysisPreviewModel(
val productRecommendationsEnabled: Boolean?,
val productAnalysis: AnalysisPresent,
val productVendor: ReviewQualityCheckState.ProductVendor,
) {
constructor(
productRecommendationsEnabled: Boolean? = false,
productId: String = "123",
reviewGrade: ReviewQualityCheckState.Grade? = ReviewQualityCheckState.Grade.B,
analysisStatus: AnalysisStatus = AnalysisStatus.UpToDate,
adjustedRating: Float? = 3.6f,
productUrl: String = "",
highlightsInfo: HighlightsInfo = HighlightsInfo(
mapOf(
HighlightType.QUALITY to listOf(
"High quality",
"Excellent craftsmanship",
"Superior materials",
),
HighlightType.PRICE to listOf(
"Affordable prices",
"Great value for money",
"Discounted offers",
),
HighlightType.SHIPPING to listOf(
"Fast and reliable shipping",
"Free shipping options",
"Express delivery",
),
HighlightType.PACKAGING_AND_APPEARANCE to listOf(
"Elegant packaging",
"Attractive appearance",
"Beautiful design",
),
HighlightType.COMPETITIVENESS to listOf(
"Competitive pricing",
"Strong market presence",
"Unbeatable deals",
),
),
),
recommendedProductState: RecommendedProductState = RecommendedProductState.Initial,
productVendor: ReviewQualityCheckState.ProductVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
) : this(
productRecommendationsEnabled = productRecommendationsEnabled,
productAnalysis = AnalysisPresent(
productId = productId,
reviewGrade = reviewGrade,
analysisStatus = analysisStatus,
adjustedRating = adjustedRating,
productUrl = productUrl,
highlightsInfo = highlightsInfo,
recommendedProductState = recommendedProductState,
),
productVendor = productVendor,
)
}
private class ProductAnalysisPreviewModelParameterProvider :
PreviewParameterProvider<ProductAnalysisPreviewModel> {
override val values: Sequence<ProductAnalysisPreviewModel>
get() = sequenceOf(
ProductAnalysisPreviewModel(),
ProductAnalysisPreviewModel(
analysisStatus = AnalysisStatus.NeedsAnalysis,
),
ProductAnalysisPreviewModel(
analysisStatus = AnalysisStatus.Reanalyzing(
ReviewQualityCheckState.OptedIn.ProductReviewState.Progress(40f),
),
),
ProductAnalysisPreviewModel(
analysisStatus = AnalysisStatus.Reanalyzing(
ReviewQualityCheckState.OptedIn.ProductReviewState.Progress(95f),
),
),
ProductAnalysisPreviewModel(
reviewGrade = null,
),
ProductAnalysisPreviewModel(
highlightsInfo = HighlightsInfo(
mapOf(
HighlightType.QUALITY to listOf(
"High quality",
"Excellent craftsmanship",
),
),
),
),
ProductAnalysisPreviewModel(
productRecommendationsEnabled = true,
recommendedProductState = RecommendedProductState.Product(
aid = "aid",
name = "The best desk ever with a really really really long product name that " +
"forces the preview to wrap its text to at least 4 lines.",
productUrl = "www.mozilla.com",
imageUrl = "https://i.fakespot.io/b6vx27xf3rgwr1a597q6qd3rutp6",
formattedPrice = "$123.45",
reviewGrade = ReviewQualityCheckState.Grade.B,
adjustedRating = 4.23f,
isSponsored = true,
analysisUrl = "",
),
),
)
}
@Composable
@LightDarkPreview
private fun ProductAnalysisPreview(
@PreviewParameter(ProductAnalysisPreviewModelParameterProvider::class) model: ProductAnalysisPreviewModel,
) {
FirefoxTheme {
ReviewQualityCheckScaffold(
onRequestDismiss = {},
) {
var productRecommendationsEnabled by remember { mutableStateOf(model.productRecommendationsEnabled) }
var isSettingsExpanded by remember { mutableStateOf(false) }
var isInfoExpanded by remember { mutableStateOf(false) }
var isHighlightsExpanded by remember { mutableStateOf(false) }
ProductAnalysis(
productRecommendationsEnabled = productRecommendationsEnabled,
productAnalysis = model.productAnalysis,
productVendor = model.productVendor,
isSettingsExpanded = isSettingsExpanded,
isInfoExpanded = isInfoExpanded,
isHighlightsExpanded = isHighlightsExpanded,
onOptOutClick = {},
onReanalyzeClick = {},
onProductRecommendationsEnabledStateChange = {
productRecommendationsEnabled = it
},
onReviewGradeLearnMoreClick = {},
onFooterLinkClick = {},
onHighlightsExpandToggleClick = { isHighlightsExpanded = !isHighlightsExpanded },
onSettingsExpandToggleClick = { isSettingsExpanded = !isSettingsExpanded },
onInfoExpandToggleClick = { isInfoExpanded = !isInfoExpanded },
onRecommendedProductClick = { _, _ -> },
onRecommendedProductImpression = {},
)
}
}
}

View File

@@ -1,205 +0,0 @@
/* 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.shopping.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.annotation.LightDarkPreview
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.InfoCard
import org.mozilla.fenix.compose.InfoCardButtonText
import org.mozilla.fenix.compose.InfoType
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState
import org.mozilla.fenix.theme.FirefoxTheme
/**
* Product analysis error UI
*
* @param error The error state to display.
* @param onReportBackInStockClick Invoked when the user clicks on the report back in stock button.
* @param productRecommendationsEnabled The current state of the product recommendations toggle.
* @param productVendor The vendor of the product.
* @param isSettingsExpanded Whether or not the settings card is expanded.
* @param isInfoExpanded Whether or not the info card is expanded.
* @param onReviewGradeLearnMoreClick Invoked when the user clicks to learn more about review grades.
* @param onOptOutClick Invoked when the user opts out of the review quality check feature.
* @param onProductRecommendationsEnabledStateChange Invoked when the user changes the product
* recommendations toggle state.
* @param onFooterLinkClick Invoked when the user clicks on the footer link.
* @param onSettingsExpandToggleClick Invoked when the user expands or collapses the settings card.
* @param onInfoExpandToggleClick Invoked when the user expands or collapses the info card.
* @param modifier Modifier to apply to the layout.
*/
@Composable
@Suppress("LongParameterList", "LongMethod")
fun ProductAnalysisError(
error: ProductReviewState.Error,
onReportBackInStockClick: () -> Unit,
productRecommendationsEnabled: Boolean?,
productVendor: ReviewQualityCheckState.ProductVendor,
isSettingsExpanded: Boolean,
isInfoExpanded: Boolean,
onReviewGradeLearnMoreClick: () -> Unit,
onOptOutClick: () -> Unit,
onProductRecommendationsEnabledStateChange: (Boolean) -> Unit,
onFooterLinkClick: () -> Unit,
onSettingsExpandToggleClick: () -> Unit,
onInfoExpandToggleClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val (
@StringRes titleResourceId: Int,
@StringRes descriptionResourceId: Int,
type: InfoType,
) = when (error) {
ProductReviewState.Error.GenericError -> {
Triple(
R.string.review_quality_check_generic_error_title,
R.string.review_quality_check_generic_error_body,
InfoType.Info,
)
}
ProductReviewState.Error.NetworkError -> {
Triple(
R.string.review_quality_check_no_connection_title,
R.string.review_quality_check_no_connection_body,
InfoType.Warning,
)
}
ProductReviewState.Error.UnsupportedProductTypeError -> {
Triple(
R.string.review_quality_check_not_analyzable_info_title,
R.string.review_quality_check_not_analyzable_info_body,
InfoType.Info,
)
}
ProductReviewState.Error.NotEnoughReviews -> {
Triple(
R.string.review_quality_check_no_reviews_warning_title,
R.string.review_quality_check_no_reviews_warning_body,
InfoType.Info,
)
}
ProductReviewState.Error.ProductNotAvailable -> {
Triple(
R.string.review_quality_check_product_availability_warning_title,
R.string.review_quality_check_product_availability_warning_body,
InfoType.Info,
)
}
ProductReviewState.Error.ProductAlreadyReported -> {
Triple(
R.string.review_quality_check_analysis_requested_other_user_info_title,
R.string.review_quality_check_analysis_requested_other_user_info_body,
InfoType.Info,
)
}
ProductReviewState.Error.ThanksForReporting -> {
Triple(
R.string.review_quality_check_analysis_requested_info_title,
R.string.review_quality_check_analysis_requested_info_body,
InfoType.Info,
)
}
}
if (error == ProductReviewState.Error.ProductNotAvailable) {
InfoCard(
title = stringResource(id = titleResourceId),
description = stringResource(id = descriptionResourceId),
type = type,
modifier = Modifier.fillMaxWidth(),
buttonText = InfoCardButtonText(
text = stringResource(R.string.review_quality_check_product_availability_warning_action_2),
onClick = onReportBackInStockClick,
),
)
} else {
InfoCard(
title = stringResource(id = titleResourceId),
description = stringResource(id = descriptionResourceId),
type = type,
modifier = Modifier.fillMaxWidth(),
)
}
ReviewQualityInfoCard(
productVendor = productVendor,
isExpanded = isInfoExpanded,
onLearnMoreClick = onReviewGradeLearnMoreClick,
onExpandToggleClick = onInfoExpandToggleClick,
)
ReviewQualityCheckSettingsCard(
productRecommendationsEnabled = productRecommendationsEnabled,
isExpanded = isSettingsExpanded,
onProductRecommendationsEnabledStateChange = onProductRecommendationsEnabledStateChange,
onTurnOffReviewQualityCheckClick = onOptOutClick,
onExpandToggleClick = onSettingsExpandToggleClick,
modifier = Modifier.fillMaxWidth(),
)
ReviewQualityCheckFooter(
onLinkClick = onFooterLinkClick,
)
}
}
@Composable
@LightDarkPreview
private fun ProductAnalysisErrorPreview() {
FirefoxTheme {
Box(
modifier = Modifier
.fillMaxWidth()
.background(color = FirefoxTheme.colors.layer1)
.padding(all = 16.dp),
) {
var productRecommendationsEnabled by remember { mutableStateOf(false) }
var isSettingsExpanded by remember { mutableStateOf(false) }
var isInfoExpanded by remember { mutableStateOf(false) }
ProductAnalysisError(
error = ProductReviewState.Error.NetworkError,
onReportBackInStockClick = { },
productRecommendationsEnabled = productRecommendationsEnabled,
productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
isSettingsExpanded = isSettingsExpanded,
isInfoExpanded = isInfoExpanded,
onReviewGradeLearnMoreClick = {},
onOptOutClick = {},
onProductRecommendationsEnabledStateChange = { productRecommendationsEnabled = it },
onFooterLinkClick = {},
onSettingsExpandToggleClick = { isSettingsExpanded = !isSettingsExpanded },
onInfoExpandToggleClick = { isInfoExpanded = !isInfoExpanded },
modifier = Modifier.fillMaxWidth(),
)
}
}
}

View File

@@ -1,95 +0,0 @@
/* 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.shopping.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.StartOffset
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.annotation.LightDarkPreview
import org.mozilla.fenix.theme.FirefoxTheme
private const val ANIMATION_DURATION_MS = 1000
private const val INITIAL_ALPHA = 0.25f
private const val TARGET_ALPHA = 1f
private val boxes = listOf(
BoxInfo(height = 80.dp, offsetMillis = 500),
BoxInfo(height = 80.dp, offsetMillis = 1000),
BoxInfo(height = 192.dp, offsetMillis = 0),
BoxInfo(height = 40.dp, offsetMillis = 500),
BoxInfo(height = 192.dp, offsetMillis = 1000),
)
/**
* Loading UI for review quality check content.
*/
@Composable
fun ProductReviewLoading(
modifier: Modifier = Modifier,
) {
val infiniteTransition = rememberInfiniteTransition("ProductReviewLoading")
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
boxes.forEach { boxInfo ->
val alpha by infiniteTransition.animateFloat(
initialValue = INITIAL_ALPHA,
targetValue = TARGET_ALPHA,
animationSpec = infiniteRepeatable(
animation = tween(ANIMATION_DURATION_MS, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
initialStartOffset = StartOffset(offsetMillis = boxInfo.offsetMillis),
),
label = "ProductReviewLoading-Alpha",
)
Box(
modifier = Modifier
.height(boxInfo.height)
.fillMaxWidth()
.alpha(alpha)
.background(
color = FirefoxTheme.colors.layer3,
shape = RoundedCornerShape(8.dp),
),
)
}
}
}
private data class BoxInfo(
val height: Dp,
val offsetMillis: Int,
)
@LightDarkPreview
@Composable
private fun ProductReviewLoadingPreview() {
FirefoxTheme {
ReviewQualityCheckScaffold(
onRequestDismiss = {},
) {
ProductReviewLoading()
}
}
}

View File

@@ -1,97 +0,0 @@
/* 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.shopping.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.annotation.LightDarkPreview
import org.mozilla.fenix.theme.FirefoxTheme
private val ProgressIndicatorWidth = 5.dp
/**
* UI displaying a circular progress indicator with a determinate value.
*
* @param progress The progress value to display.
* @param modifier [Modifier] to be applied to the indicator.
*/
@Composable
fun DeterminateProgressIndicator(
progress: Float,
modifier: Modifier = Modifier,
) {
val floatState = animateFloatAsState(
progress,
label = "DeterminateProgressIndicator",
)
CircularProgressIndicator(
modifier = modifier,
progress = floatState.value,
color = FirefoxTheme.colors.layerAccent,
backgroundColor = FirefoxTheme.colors.actionTertiary,
strokeWidth = ProgressIndicatorWidth,
strokeCap = StrokeCap.Butt,
)
}
@LightDarkPreview
@Composable
private fun DeterminateProgressIndicatorPreviewDark() {
FirefoxTheme {
Column(
modifier = Modifier
.background(FirefoxTheme.colors.layer1)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
var progress: Float by remember { mutableFloatStateOf(0f) }
DeterminateProgressIndicator(
progress = progress,
modifier = Modifier.size(48.dp),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Button(
onClick = { progress += 0.1f },
modifier = Modifier.height(56.dp),
) {
Text(text = "Increase progress")
}
Button(
onClick = { progress -= 0.1f },
modifier = Modifier.height(56.dp),
) {
Text(text = "Decrease progress")
}
}
}
}
}

View File

@@ -1,212 +0,0 @@
/* 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.shopping.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.annotation.LightDarkPreview
import mozilla.components.ui.colors.PhotonColors
import org.mozilla.fenix.R
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.Grade
import org.mozilla.fenix.theme.FirefoxTheme
private val height = 24.dp
private val borderColor = Color(0x26000000)
private val reviewGradeAColor = PhotonColors.Green20
private val reviewGradeBColor = PhotonColors.Blue10
private val reviewGradeCColor = PhotonColors.Yellow20
private val reviewGradeDColor = PhotonColors.Orange20
private val reviewGradeFColor = PhotonColors.Red30
private val reviewGradeAColorExpanded = Color(0xFFEEFFF9)
private val reviewGradeBColorExpanded = Color(0xFFDEFAFF)
private val reviewGradeCColorExpanded = Color(0xFFFFF9DA)
private val reviewGradeDColorExpanded = Color(0xFFFDEEE2)
private val reviewGradeFColorExpanded = Color(0xFFFFEFF0)
/**
* Review Grade of the product - A being the best and F being the worst.
*/
private enum class ReviewGrade(
val stringResourceId: Int,
val backgroundColor: Color,
val expandedTextBackgroundColor: Color,
) {
A(
stringResourceId = R.string.review_quality_check_grade_a_b_description,
backgroundColor = reviewGradeAColor,
expandedTextBackgroundColor = reviewGradeAColorExpanded,
),
B(
stringResourceId = R.string.review_quality_check_grade_a_b_description,
backgroundColor = reviewGradeBColor,
expandedTextBackgroundColor = reviewGradeBColorExpanded,
),
C(
stringResourceId = R.string.review_quality_check_grade_c_description,
backgroundColor = reviewGradeCColor,
expandedTextBackgroundColor = reviewGradeCColorExpanded,
),
D(
stringResourceId = R.string.review_quality_check_grade_d_f_description,
backgroundColor = reviewGradeDColor,
expandedTextBackgroundColor = reviewGradeDColorExpanded,
),
F(
stringResourceId = R.string.review_quality_check_grade_d_f_description,
backgroundColor = reviewGradeFColor,
expandedTextBackgroundColor = reviewGradeFColorExpanded,
),
}
/**
* UI for displaying the review grade.
*
* @param modifier The modifier to be applied to the Composable.
* @param grade The grade of the product.
*/
@Composable
fun ReviewGradeCompact(
modifier: Modifier = Modifier,
grade: Grade,
) {
ReviewGradeLetter(
reviewGrade = grade.toReviewGrade(),
modifier = modifier.border(
border = BorderStroke(
width = 1.dp,
color = borderColor,
),
shape = MaterialTheme.shapes.small,
),
)
}
/**
* UI for displaying the review grade with descriptive text.
*
* @param modifier The modifier to be applied to the Composable.
* @param grade The grade of the product.
*/
@Composable
fun ReviewGradeExpanded(
modifier: Modifier = Modifier,
grade: Grade,
) {
val reviewGrade = grade.toReviewGrade()
Row(
modifier = modifier
.background(
color = reviewGrade.expandedTextBackgroundColor,
shape = MaterialTheme.shapes.small,
)
.border(
border = BorderStroke(
width = 1.dp,
color = borderColor,
),
shape = MaterialTheme.shapes.small,
),
verticalAlignment = Alignment.CenterVertically,
) {
val cornerSize = CornerSize(0.dp)
val shape =
MaterialTheme.shapes.small.copy(topEnd = cornerSize, bottomEnd = cornerSize)
ReviewGradeLetter(
reviewGrade = reviewGrade,
shape = shape,
)
Text(
text = stringResource(id = reviewGrade.stringResourceId),
color = PhotonColors.DarkGrey90,
style = FirefoxTheme.typography.body2,
modifier = Modifier.padding(horizontal = 8.dp),
)
}
}
/**
* Common UI building block for the review grade.
*/
@Composable
private fun ReviewGradeLetter(
modifier: Modifier = Modifier,
reviewGrade: ReviewGrade,
shape: Shape = MaterialTheme.shapes.small,
) {
Box(
modifier = modifier
.size(height)
.background(
color = reviewGrade.backgroundColor,
shape = shape,
)
.wrapContentSize(Alignment.Center),
) {
Text(
text = reviewGrade.name,
color = PhotonColors.Black,
style = FirefoxTheme.typography.subtitle2,
)
}
}
/**
* Maps [Grade] to [ReviewGrade].
*/
private fun Grade.toReviewGrade(): ReviewGrade =
when (this) {
Grade.A -> ReviewGrade.A
Grade.B -> ReviewGrade.B
Grade.C -> ReviewGrade.C
Grade.D -> ReviewGrade.D
Grade.F -> ReviewGrade.F
}
@Composable
@LightDarkPreview
private fun ReviewGradePreview() {
FirefoxTheme {
Column(
modifier = Modifier
.background(FirefoxTheme.colors.layer1)
.padding(16.dp),
) {
Grade.entries.forEach {
Row(
horizontalArrangement = Arrangement.spacedBy(32.dp),
) {
ReviewGradeCompact(grade = it)
ReviewGradeExpanded(grade = it)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}

View File

@@ -1,215 +0,0 @@
/* 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.shopping.ui
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import mozilla.components.lib.state.ext.observeAsState
import org.mozilla.fenix.shopping.store.BottomSheetDismissSource
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent
import org.mozilla.fenix.shopping.store.ReviewQualityCheckStore
/**
* Top-level UI for the Review Quality Check feature.
*
* @param store [ReviewQualityCheckStore] that holds the state.
* @param onRequestDismiss Invoked when a user action requests dismissal of the bottom sheet.
* @param modifier The modifier to be applied to the Composable.
*/
@Suppress("LongMethod")
@Composable
fun ReviewQualityCheckBottomSheet(
store: ReviewQualityCheckStore,
onRequestDismiss: (source: BottomSheetDismissSource) -> Unit,
modifier: Modifier = Modifier,
) {
val reviewQualityCheckState by store.observeAsState(ReviewQualityCheckState.Initial) { it }
val isOptedIn =
remember(reviewQualityCheckState) { reviewQualityCheckState is ReviewQualityCheckState.OptedIn }
ReviewQualityCheckScaffold(
onRequestDismiss = {
onRequestDismiss(BottomSheetDismissSource.HANDLE_CLICKED)
},
modifier = modifier.animateContentSize(),
) {
when (val state = reviewQualityCheckState) {
is ReviewQualityCheckState.NotOptedIn -> {
ReviewQualityCheckContextualOnboarding(
productVendors = state.productVendors,
onPrimaryButtonClick = {
store.dispatch(ReviewQualityCheckAction.OptIn)
},
onLearnMoreClick = {
onRequestDismiss(BottomSheetDismissSource.LINK_OPENED)
store.dispatch(ReviewQualityCheckAction.OpenOnboardingLearnMoreLink)
},
onPrivacyPolicyClick = {
onRequestDismiss(BottomSheetDismissSource.LINK_OPENED)
store.dispatch(ReviewQualityCheckAction.OpenOnboardingPrivacyPolicyLink)
},
onTermsOfUseClick = {
onRequestDismiss(BottomSheetDismissSource.LINK_OPENED)
store.dispatch(ReviewQualityCheckAction.OpenOnboardingTermsLink)
},
onSecondaryButtonClick = {
onRequestDismiss(BottomSheetDismissSource.NOT_NOW)
store.dispatch(ReviewQualityCheckAction.NotNowClicked)
},
)
}
is ReviewQualityCheckState.OptedIn -> {
ProductReview(
state = state,
onOptOutClick = {
onRequestDismiss(BottomSheetDismissSource.OPT_OUT)
store.dispatch(ReviewQualityCheckAction.OptOut)
},
onAnalyzeClick = {
store.dispatch(ReviewQualityCheckAction.AnalyzeProduct)
},
onReanalyzeClick = {
store.dispatch(ReviewQualityCheckAction.ReanalyzeProduct)
},
onReportBackInStockClick = {
store.dispatch(ReviewQualityCheckAction.ReportProductBackInStock)
},
onProductRecommendationsEnabledStateChange = {
store.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation)
},
onReviewGradeLearnMoreClick = {
onRequestDismiss(BottomSheetDismissSource.LINK_OPENED)
store.dispatch(ReviewQualityCheckAction.OpenExplainerLearnMoreLink)
},
onFooterLinkClick = {
onRequestDismiss(BottomSheetDismissSource.LINK_OPENED)
store.dispatch(ReviewQualityCheckAction.OpenPoweredByLink)
},
onSettingsExpandToggleClick = {
store.dispatch(ReviewQualityCheckAction.ExpandCollapseSettings)
},
onInfoExpandToggleClick = {
store.dispatch(ReviewQualityCheckAction.ExpandCollapseInfo)
},
onNoAnalysisPresent = {
store.dispatch(ReviewQualityCheckAction.NoAnalysisDisplayed)
},
onHighlightsExpandToggleClick = {
store.dispatch(ReviewQualityCheckAction.ExpandCollapseHighlights)
},
onRecommendedProductClick = { aid, url ->
onRequestDismiss(BottomSheetDismissSource.LINK_OPENED)
store.dispatch(ReviewQualityCheckAction.RecommendedProductClick(aid, url))
},
onProductRecommendationImpression = { aid ->
store.dispatch(ReviewQualityCheckAction.RecommendedProductImpression(productAid = aid))
},
)
}
is ReviewQualityCheckState.Initial -> {}
}
}
LaunchedEffect(isOptedIn) {
if (isOptedIn) {
store.dispatch(ReviewQualityCheckAction.FetchProductAnalysis)
}
}
}
@Composable
@Suppress("LongParameterList")
private fun ProductReview(
state: ReviewQualityCheckState.OptedIn,
onOptOutClick: () -> Unit,
onAnalyzeClick: () -> Unit,
onReanalyzeClick: () -> Unit,
onProductRecommendationsEnabledStateChange: (Boolean) -> Unit,
onHighlightsExpandToggleClick: () -> Unit,
onNoAnalysisPresent: () -> Unit,
onSettingsExpandToggleClick: () -> Unit,
onInfoExpandToggleClick: () -> Unit,
onReviewGradeLearnMoreClick: () -> Unit,
onFooterLinkClick: () -> Unit,
onRecommendedProductClick: (aid: String, url: String) -> Unit,
onProductRecommendationImpression: (aid: String) -> Unit,
onReportBackInStockClick: () -> Unit,
) {
when (val productReviewState = state.productReviewState) {
is AnalysisPresent -> {
ProductAnalysis(
productRecommendationsEnabled = state.productRecommendationsPreference,
productAnalysis = productReviewState,
productVendor = state.productVendor,
isSettingsExpanded = state.isSettingsExpanded,
isInfoExpanded = state.isInfoExpanded,
isHighlightsExpanded = state.isHighlightsExpanded,
onOptOutClick = onOptOutClick,
onReanalyzeClick = onReanalyzeClick,
onProductRecommendationsEnabledStateChange = onProductRecommendationsEnabledStateChange,
onHighlightsExpandToggleClick = onHighlightsExpandToggleClick,
onSettingsExpandToggleClick = onSettingsExpandToggleClick,
onInfoExpandToggleClick = onInfoExpandToggleClick,
onReviewGradeLearnMoreClick = onReviewGradeLearnMoreClick,
onFooterLinkClick = onFooterLinkClick,
onRecommendedProductClick = onRecommendedProductClick,
onRecommendedProductImpression = onProductRecommendationImpression,
)
}
is ReviewQualityCheckState.OptedIn.ProductReviewState.Error -> {
ProductAnalysisError(
error = productReviewState,
onReportBackInStockClick = onReportBackInStockClick,
productRecommendationsEnabled = state.productRecommendationsPreference,
productVendor = state.productVendor,
isSettingsExpanded = state.isSettingsExpanded,
isInfoExpanded = state.isInfoExpanded,
onReviewGradeLearnMoreClick = onReviewGradeLearnMoreClick,
onOptOutClick = onOptOutClick,
onProductRecommendationsEnabledStateChange = onProductRecommendationsEnabledStateChange,
onFooterLinkClick = onFooterLinkClick,
onSettingsExpandToggleClick = onSettingsExpandToggleClick,
onInfoExpandToggleClick = onInfoExpandToggleClick,
modifier = Modifier.fillMaxWidth(),
)
}
is ReviewQualityCheckState.OptedIn.ProductReviewState.Loading -> {
ProductReviewLoading()
}
is ReviewQualityCheckState.OptedIn.ProductReviewState.NoAnalysisPresent -> {
LaunchedEffect(Unit) {
onNoAnalysisPresent()
}
NoAnalysis(
noAnalysisPresent = productReviewState,
onAnalyzeClick = onAnalyzeClick,
productRecommendationsEnabled = state.productRecommendationsPreference,
productVendor = state.productVendor,
isSettingsExpanded = state.isSettingsExpanded,
isInfoExpanded = state.isInfoExpanded,
onReviewGradeLearnMoreClick = onReviewGradeLearnMoreClick,
onOptOutClick = onOptOutClick,
onProductRecommendationsEnabledStateChange = onProductRecommendationsEnabledStateChange,
onSettingsExpandToggleClick = onSettingsExpandToggleClick,
onInfoExpandToggleClick = onInfoExpandToggleClick,
onFooterLinkClick = onFooterLinkClick,
modifier = Modifier.fillMaxWidth(),
)
}
}
}

View File

@@ -1,232 +0,0 @@
/* 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.shopping.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.annotation.LightDarkPreview
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.InfoCardContainer
import org.mozilla.fenix.compose.LinkText
import org.mozilla.fenix.compose.LinkTextState
import org.mozilla.fenix.compose.button.PrimaryButton
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.ProductVendor
import org.mozilla.fenix.shopping.ui.ext.displayName
import org.mozilla.fenix.shopping.ui.ext.headingResource
import org.mozilla.fenix.theme.FirefoxTheme
private const val MAX_SUPPORTED_VENDORS_PER_TLD = 3
/**
* A placeholder UI for review quality check contextual onboarding. The actual UI will be
* implemented as part of Bug 1840103 with the illustration.
*
* @param productVendors List of retailers to be displayed in order.
* @param onLearnMoreClick Invoked when a user clicks on the learn more link.
* @param onPrivacyPolicyClick Invoked when a user clicks on the privacy policy link.
* @param onTermsOfUseClick Invoked when a user clicks on the terms of use link.
* @param onPrimaryButtonClick Invoked when a user clicks on the primary button.
* @param onSecondaryButtonClick Invoked when a user clicks on the secondary button.
*/
@Suppress("LongMethod")
@Composable
fun ReviewQualityCheckContextualOnboarding(
productVendors: List<ProductVendor>,
onLearnMoreClick: () -> Unit,
onPrivacyPolicyClick: () -> Unit,
onTermsOfUseClick: () -> Unit,
onPrimaryButtonClick: () -> Unit,
onSecondaryButtonClick: () -> Unit,
) {
val learnMoreText =
stringResource(id = R.string.review_quality_check_contextual_onboarding_learn_more_link)
val privacyPolicyText =
stringResource(id = R.string.review_quality_check_contextual_onboarding_privacy_policy_3)
val termsOfUseText =
stringResource(id = R.string.review_quality_check_contextual_onboarding_terms_use)
val titleContentDescription =
headingResource(R.string.review_quality_check_contextual_onboarding_title)
InfoCardContainer(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.review_quality_check_contextual_onboarding_title),
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.headline5,
modifier = Modifier.semantics {
heading()
contentDescription = titleContentDescription
},
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = createDescriptionString(productVendors),
color = FirefoxTheme.colors.textSecondary,
style = FirefoxTheme.typography.body2,
)
Spacer(modifier = Modifier.height(16.dp))
LinkText(
text = stringResource(
id = R.string.review_quality_check_contextual_onboarding_learn_more,
stringResource(id = R.string.shopping_product_name),
learnMoreText,
),
linkTextStates = listOf(
LinkTextState(
text = learnMoreText,
url = "",
onClick = {
onLearnMoreClick()
},
),
),
style = FirefoxTheme.typography.body2.copy(
color = FirefoxTheme.colors.textSecondary,
),
linkTextDecoration = TextDecoration.Underline,
)
Spacer(modifier = Modifier.height(16.dp))
LinkText(
text = stringResource(
id = R.string.review_quality_check_contextual_onboarding_caption_4,
stringResource(id = R.string.firefox),
privacyPolicyText,
stringResource(id = R.string.shopping_product_name),
termsOfUseText,
),
linkTextStates = listOf(
LinkTextState(
text = privacyPolicyText,
url = "",
onClick = {
onPrivacyPolicyClick()
},
),
LinkTextState(
text = termsOfUseText,
url = "",
onClick = {
onTermsOfUseClick()
},
),
),
style = FirefoxTheme.typography.caption
.copy(
color = FirefoxTheme.colors.textSecondary,
),
linkTextDecoration = TextDecoration.Underline,
)
Spacer(modifier = Modifier.height(16.dp))
Image(
painter = painterResource(id = R.drawable.shopping_onboarding),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(all = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
PrimaryButton(
text = stringResource(R.string.review_quality_check_contextual_onboarding_primary_button_text),
onClick = onPrimaryButtonClick,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = onSecondaryButtonClick,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.review_quality_check_contextual_onboarding_secondary_button_text),
color = FirefoxTheme.colors.textAccent,
style = FirefoxTheme.typography.button,
maxLines = 1,
)
}
}
}
@Composable
private fun createDescriptionString(
retailers: List<ProductVendor>,
) = buildAnnotatedString {
val retailerNames = retailers.map { it.displayName() }
val description = if (retailers.size == MAX_SUPPORTED_VENDORS_PER_TLD) {
stringResource(
id = R.string.review_quality_check_contextual_onboarding_description,
retailerNames[0],
stringResource(R.string.app_name),
retailerNames[1],
retailerNames[2],
)
} else {
stringResource(
id = R.string.review_quality_check_contextual_onboarding_description_one_vendor,
retailerNames.first(),
stringResource(R.string.app_name),
)
}
append(description)
retailerNames.forEach { retailer ->
val start = description.indexOf(retailer)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Bold),
start = start,
end = start + retailer.length,
)
}
}
@Composable
@LightDarkPreview
private fun ProductAnalysisPreview() {
FirefoxTheme {
ReviewQualityCheckScaffold(
onRequestDismiss = {},
) {
ReviewQualityCheckContextualOnboarding(
productVendors = ReviewQualityCheckState.NotOptedIn().productVendors,
onPrimaryButtonClick = {},
onLearnMoreClick = {},
onPrivacyPolicyClick = {},
onTermsOfUseClick = {},
onSecondaryButtonClick = {},
)
}
}
}

View File

@@ -1,71 +0,0 @@
/* 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.shopping.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.annotation.LightDarkPreview
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.LinkText
import org.mozilla.fenix.compose.LinkTextState
import org.mozilla.fenix.theme.FirefoxTheme
/**
* Review Quality Check footer with an embedded link to navigate to Fakespot.com.
*
* @param onLinkClick Invoked when the user clicks on the embedded link.
*/
@Composable
fun ReviewQualityCheckFooter(
onLinkClick: () -> Unit,
) {
val poweredByLinkText = stringResource(
id = R.string.review_quality_check_powered_by_link,
stringResource(id = R.string.shopping_product_name),
)
LinkText(
text = stringResource(
id = R.string.review_quality_check_powered_by_2,
poweredByLinkText,
),
linkTextStates = listOf(
LinkTextState(
text = poweredByLinkText,
url = "",
onClick = {
onLinkClick()
},
),
),
style = FirefoxTheme.typography.body2.copy(
color = FirefoxTheme.colors.textSecondary,
),
linkTextColor = FirefoxTheme.colors.textAccent,
)
}
@LightDarkPreview
@Composable
private fun ReviewQualityCheckFooterPreview() {
FirefoxTheme {
Box(
modifier = Modifier
.fillMaxWidth()
.background(color = FirefoxTheme.colors.layer1)
.padding(all = 16.dp),
) {
ReviewQualityCheckFooter(
onLinkClick = {},
)
}
}
}

View File

@@ -1,132 +0,0 @@
/* 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.shopping.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.annotation.LightDarkPreview
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.BetaLabel
import org.mozilla.fenix.compose.BottomSheetHandle
import org.mozilla.fenix.shopping.ui.ext.headingResource
import org.mozilla.fenix.theme.FirefoxTheme
private val bottomSheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
private const val BOTTOM_SHEET_HANDLE_WIDTH_PERCENT = 0.1f
/**
* A scaffold for review quality check UI that implements the basic layout structure with
* [BottomSheetHandle], [Header] and [content].
*
* @param onRequestDismiss Invoked when a user action requests dismissal of the bottom sheet.
* @param modifier The modifier to be applied to the Composable.
* @param content The content of the bottom sheet.
*/
@Composable
fun ReviewQualityCheckScaffold(
onRequestDismiss: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
color = FirefoxTheme.colors.layer1,
shape = bottomSheetShape,
) {
Column(
modifier = modifier
.background(
color = FirefoxTheme.colors.layer1,
shape = bottomSheetShape,
)
.verticalScroll(rememberScrollState())
.padding(
vertical = 8.dp,
horizontal = 16.dp,
),
) {
BottomSheetHandle(
onRequestDismiss = onRequestDismiss,
contentDescription = stringResource(R.string.review_quality_check_close_handle_content_description),
modifier = Modifier
.fillMaxWidth(BOTTOM_SHEET_HANDLE_WIDTH_PERCENT)
.align(Alignment.CenterHorizontally)
.semantics { traversalIndex = -1f },
)
Spacer(modifier = Modifier.height(16.dp))
Header()
Spacer(modifier = Modifier.height(16.dp))
content()
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@Composable
private fun Header() {
val reviewCheckerFeatureName = stringResource(R.string.review_quality_check_feature_name_2)
val betaText = stringResource(R.string.beta_feature)
val titleContentDescription = headingResource("$reviewCheckerFeatureName, $betaText")
Row(
modifier = Modifier.semantics(mergeDescendants = true) {
heading()
contentDescription = titleContentDescription
},
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = reviewCheckerFeatureName,
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.headline6,
modifier = Modifier.clearAndSetSemantics {},
)
Spacer(modifier = Modifier.width(8.dp))
BetaLabel(modifier = Modifier.clearAndSetSemantics {})
}
}
@LightDarkPreview
@Composable
private fun HeaderPreview() {
FirefoxTheme {
Box(
modifier = Modifier
.background(color = FirefoxTheme.colors.layer1)
.padding(16.dp),
) {
Header()
}
}
}

View File

@@ -1,115 +0,0 @@
/* 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.shopping.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.annotation.LightDarkPreview
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.ExpandableInfoCardContainer
import org.mozilla.fenix.compose.SwitchWithLabel
import org.mozilla.fenix.compose.button.SecondaryButton
import org.mozilla.fenix.theme.FirefoxTheme
/**
* Review quality check settings card UI. Contains toggles to disable product recommendations and
* the entire review quality check feature.
*
* @param productRecommendationsEnabled The current state of the product recommendations toggle.
* @param isExpanded Whether or not the settings card is expanded.
* @param onProductRecommendationsEnabledStateChange Invoked when the user changes the product
* recommendations toggle state.
* @param onTurnOffReviewQualityCheckClick Invoked when the user opts out of the review quality check feature.
* @param onExpandToggleClick Invoked when the user expands or collapses the settings card.
* @param modifier Modifier to apply to the layout.
*/
@Composable
fun ReviewQualityCheckSettingsCard(
productRecommendationsEnabled: Boolean?,
isExpanded: Boolean,
onProductRecommendationsEnabledStateChange: (Boolean) -> Unit,
onTurnOffReviewQualityCheckClick: () -> Unit,
onExpandToggleClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ExpandableInfoCardContainer(
modifier = modifier,
title = stringResource(R.string.review_quality_check_settings_title),
isExpanded = isExpanded,
onExpandToggleClick = onExpandToggleClick,
) {
SettingsContent(
productRecommendationsEnabled = productRecommendationsEnabled,
onProductRecommendationsEnabledStateChange = onProductRecommendationsEnabledStateChange,
onTurnOffReviewQualityCheckClick = onTurnOffReviewQualityCheckClick,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun SettingsContent(
productRecommendationsEnabled: Boolean?,
onProductRecommendationsEnabledStateChange: (Boolean) -> Unit,
onTurnOffReviewQualityCheckClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Spacer(modifier = Modifier.height(8.dp))
if (productRecommendationsEnabled != null) {
SwitchWithLabel(
label = stringResource(R.string.review_quality_check_settings_recommended_products),
checked = productRecommendationsEnabled,
onCheckedChange = onProductRecommendationsEnabledStateChange,
)
Spacer(modifier = Modifier.height(16.dp))
}
SecondaryButton(
text = stringResource(R.string.review_quality_check_settings_turn_off),
onClick = onTurnOffReviewQualityCheckClick,
)
}
}
@LightDarkPreview
@Composable
private fun ReviewQualityCheckSettingsCardPreview() {
FirefoxTheme {
Box(
modifier = Modifier
.fillMaxWidth()
.background(color = FirefoxTheme.colors.layer1)
.padding(all = 16.dp),
) {
var isSettingsExpanded by remember { mutableStateOf(true) }
var productRecommendationsEnabled by remember { mutableStateOf(true) }
ReviewQualityCheckSettingsCard(
productRecommendationsEnabled = productRecommendationsEnabled,
onProductRecommendationsEnabledStateChange = { productRecommendationsEnabled = it },
onTurnOffReviewQualityCheckClick = {},
isExpanded = isSettingsExpanded,
onExpandToggleClick = { isSettingsExpanded = !isSettingsExpanded },
modifier = Modifier.fillMaxWidth(),
)
}
}
}

View File

@@ -1,213 +0,0 @@
/* 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.shopping.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.annotation.LightDarkPreview
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.ExpandableInfoCardContainer
import org.mozilla.fenix.compose.LinkText
import org.mozilla.fenix.compose.LinkTextState
import org.mozilla.fenix.compose.parseHtml
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.ui.ext.displayName
import org.mozilla.fenix.theme.FirefoxTheme
/**
* Info card UI containing an explanation of the review quality.
*
* @param productVendor The vendor of the product.
* @param isExpanded Whether or not the card is expanded.
* @param modifier Modifier to apply to the layout.
* @param onExpandToggleClick Invoked when the user expands or collapses the card.
* @param onLearnMoreClick Invoked when the user clicks to learn more about review grades.
*/
@Composable
fun ReviewQualityInfoCard(
productVendor: ReviewQualityCheckState.ProductVendor,
isExpanded: Boolean,
modifier: Modifier = Modifier,
onExpandToggleClick: () -> Unit,
onLearnMoreClick: () -> Unit,
) {
ExpandableInfoCardContainer(
title = stringResource(id = R.string.review_quality_check_explanation_title),
modifier = modifier,
isExpanded = isExpanded,
onExpandToggleClick = onExpandToggleClick,
) {
ReviewQualityInfo(
productVendor = productVendor,
modifier = Modifier.fillMaxWidth(),
onLearnMoreClick = onLearnMoreClick,
)
}
}
@Suppress("LongMethod")
@Composable
private fun ReviewQualityInfo(
productVendor: ReviewQualityCheckState.ProductVendor,
modifier: Modifier = Modifier,
onLearnMoreClick: () -> Unit,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
val letterGradeText =
stringResource(id = R.string.review_quality_check_info_review_grade_header)
val adjustedGradingText =
stringResource(id = R.string.review_quality_check_explanation_body_adjusted_grading)
val highlightsText = stringResource(
id = R.string.review_quality_check_explanation_body_highlights,
productVendor.displayName(),
)
Text(
text = stringResource(
id = R.string.review_quality_check_explanation_body_reliability,
stringResource(R.string.shopping_product_name),
),
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.body2,
)
Text(
text = remember(letterGradeText) { parseHtml(letterGradeText) },
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.body2,
)
ReviewGradingScaleInfo(
reviewGrades = listOf(
ReviewQualityCheckState.Grade.A,
ReviewQualityCheckState.Grade.B,
),
info = stringResource(id = R.string.review_quality_check_info_grade_info_AB),
modifier = Modifier.fillMaxWidth(),
)
ReviewGradingScaleInfo(
reviewGrades = listOf(ReviewQualityCheckState.Grade.C),
info = stringResource(id = R.string.review_quality_check_info_grade_info_C),
modifier = Modifier.fillMaxWidth(),
)
ReviewGradingScaleInfo(
reviewGrades = listOf(
ReviewQualityCheckState.Grade.D,
ReviewQualityCheckState.Grade.F,
),
info = stringResource(id = R.string.review_quality_check_info_grade_info_DF),
modifier = Modifier.fillMaxWidth(),
)
Text(
text = remember(adjustedGradingText) { parseHtml(adjustedGradingText) },
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.body2,
)
Text(
text = remember(highlightsText) { parseHtml(highlightsText) },
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.body2,
)
val link = stringResource(
id = R.string.review_quality_check_info_learn_more_link_2,
stringResource(R.string.shopping_product_name),
)
val text = stringResource(R.string.review_quality_check_info_learn_more, link)
LinkText(
text = text,
linkTextStates = listOf(
LinkTextState(
text = link,
url = "",
onClick = {
onLearnMoreClick()
},
),
),
style = FirefoxTheme.typography.body2.copy(
color = FirefoxTheme.colors.textPrimary,
),
linkTextColor = FirefoxTheme.colors.textAccent,
linkTextDecoration = TextDecoration.Underline,
)
}
}
@Composable
private fun ReviewGradingScaleInfo(
reviewGrades: List<ReviewQualityCheckState.Grade>,
info: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.semantics(mergeDescendants = true) {},
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
reviewGrades.forEach { grade ->
ReviewGradeCompact(grade = grade)
}
if (reviewGrades.size == 1) {
Spacer(modifier = Modifier.width(24.dp))
}
Text(
text = info,
color = FirefoxTheme.colors.textPrimary,
style = FirefoxTheme.typography.body2,
)
}
}
@Composable
@LightDarkPreview
private fun ReviewQualityInfoCardPreview() {
FirefoxTheme {
Box(
modifier = Modifier
.fillMaxWidth()
.background(color = FirefoxTheme.colors.layer1)
.padding(all = 16.dp),
) {
var isInfoExpanded by remember { mutableStateOf(true) }
ReviewQualityInfoCard(
productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
isExpanded = isInfoExpanded,
modifier = Modifier.fillMaxWidth(),
onLearnMoreClick = {},
onExpandToggleClick = { isInfoExpanded = !isInfoExpanded },
)
}
}
}

View File

@@ -1,126 +0,0 @@
/* 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.shopping.ui
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.annotation.LightDarkPreview
import org.mozilla.fenix.R
import org.mozilla.fenix.theme.FirefoxTheme
import kotlin.math.max
import kotlin.math.min
import kotlin.math.round
import kotlin.math.roundToInt
private const val NUM_STARS = 5
private const val MAX_RATING = 5f
private const val MIN_RATING = 0f
/**
* UI for displaying star rating bar with maximum 5 stars.
*
* @param value The rating to be displayed as filled stars.
* @param modifier The modifier to be applied to the composable.
*/
@Composable
fun StarRating(
value: Float,
modifier: Modifier = Modifier,
) {
val rating: Float = remember(value) {
max(min(MAX_RATING, value), MIN_RATING).roundToNearestHalf()
}
val contentDescription = contentDescription(rating = value)
Row(
modifier = modifier.semantics {
this.contentDescription = contentDescription
},
) {
repeat(NUM_STARS) {
val starId: Int
val colorFilter: ColorFilter?
if (it < rating && it + 1 > rating) {
starId = R.drawable.mozac_ic_star_one_half_fill_20
colorFilter = null // use the colors values in the vector
} else if (it < rating) {
starId = R.drawable.mozac_ic_star_fill_20
colorFilter = ColorFilter.tint(colorResource(id = R.color.mozac_ic_star_filled))
} else {
starId = R.drawable.mozac_ic_star_fill_20
colorFilter = ColorFilter.tint(colorResource(id = R.color.mozac_ic_star_unfilled))
}
Image(
painter = painterResource(id = starId),
colorFilter = colorFilter,
contentDescription = null,
)
}
}
}
@Composable
private fun contentDescription(rating: Float): String {
val formattedRating: Number = remember(rating) { rating.removeDecimalZero() }
return stringResource(
R.string.review_quality_check_star_rating_content_description,
formattedRating,
)
}
/**
* Removes decimal zero if present and returns an Int, otherwise returns the same float as Number.
* e.g.4.0 becomes 4, 3.4 stays 3.4.
*/
@VisibleForTesting
fun Float.removeDecimalZero(): Number =
if (this % 1 == 0f) {
roundToInt()
} else {
this
}
/**
* Rounds the float to the nearest half instead of the whole number.
* e.g 4.6 becomes 4.5 instead of 5.
*/
@VisibleForTesting
fun Float.roundToNearestHalf(): Float =
round(this * 2) / 2f
@LightDarkPreview
@Composable
@Suppress("MagicNumber")
private fun StarRatingPreview() {
FirefoxTheme {
Column(
modifier = Modifier
.background(FirefoxTheme.colors.layer1)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(0.4f, 0.9f, 1.6f, 2.2f, 3f, 3.6f, 4.1f, 4.65f, 4.9f).forEach {
StarRating(value = it)
}
}
}
}

View File

@@ -1,20 +0,0 @@
/* 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.shopping.ui.ext
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import org.mozilla.fenix.R
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.ProductVendor
/**
* Returns the display string corresponding to the particular [ProductVendor].
*/
@Composable
fun ProductVendor.displayName(): String = when (this) {
ProductVendor.AMAZON -> stringResource(id = R.string.review_quality_check_retailer_name_amazon)
ProductVendor.BEST_BUY -> stringResource(id = R.string.review_quality_check_retailer_name_bestbuy)
ProductVendor.WALMART -> stringResource(id = R.string.review_quality_check_retailer_name_walmart)
}

View File

@@ -1750,22 +1750,6 @@ class Settings(private val appContext: Context) : PreferencesHolder {
},
)
/**
* Indicates if the review quality check feature is enabled by the user.
*/
var isReviewQualityCheckEnabled by booleanPreference(
key = appContext.getPreferenceKey(R.string.pref_key_is_review_quality_check_enabled),
default = false,
)
/**
* Indicates if the review quality check product recommendations option is enabled by the user.
*/
var isReviewQualityCheckProductRecommendationsEnabled by booleanPreference(
key = appContext.getPreferenceKey(R.string.pref_key_is_review_quality_check_product_recommendations_enabled),
default = false,
)
/**
* Indicates if the navigation bar CFR should be displayed to the user.
*/
@@ -1798,14 +1782,6 @@ class Settings(private val appContext: Context) : PreferencesHolder {
default = true,
)
/**
* Time in milliseconds since the user first opted in the review quality check feature.
*/
var reviewQualityCheckOptInTimeInMillis by longPreference(
appContext.getPreferenceKey(R.string.pref_key_should_show_review_quality_opt_in_time),
default = 0L,
)
/**
* Get the current mode for how https-only is enabled.
*/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13.452,2H6v1.75h7.088l6.629,6.656 1.24,-1.234 -6.885,-6.914a0.875,0.875 0,0 0,-0.62 -0.258zM8.933,12.379l1.414,-1.415L8.933,9.55l-1.415,1.414 1.415,1.415z" >
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="24"
android:startY="0"
android:endX="0"
android:endY="24"
android:startColor="@color/fx_mobile_icon_color_gradient_start"
android:endColor="@color/fx_mobile_icon_color_gradient_end"/>
</aapt:attr>
</path>
<path
android:pathData="M3.5,6.375c0,-0.483 0.392,-0.875 0.875,-0.875h7.265c0.232,0 0.455,0.092 0.619,0.256l6.735,6.735a2.5,2.5 0,0 1,0 3.536l-5.215,5.215a2.5,2.5 0,0 1,-3.536 0l-6.487,-6.487a0.875,0.875 0,0 1,-0.256 -0.619L3.5,6.375zM5.25,7.25v6.524l6.23,6.23a0.75,0.75 0,0 0,1.061 0l5.215,-5.215a0.75,0.75 0,0 0,0 -1.06L11.278,7.25L5.25,7.25z" >
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="24"
android:startY="0"
android:endX="0"
android:endY="24"
android:startColor="@color/fx_mobile_icon_color_gradient_start"
android:endColor="@color/fx_mobile_icon_color_gradient_end"/>
</aapt:attr>
</path>
</vector>

View File

@@ -178,10 +178,6 @@
<color name="mozac_ui_tabcounter_default_tint" tools:ignore="UnusedResources">@color/fx_mobile_icon_color_primary</color>
<color name="mozac_ui_tabcounter_default_text" tools:ignore="UnusedResources">@color/fx_mobile_text_color_primary</color>
<!-- Star icon fill colors -->
<color name="mozac_ic_star_filled">@color/photonLightGrey40</color>
<color name="mozac_ic_star_unfilled">@color/photonDarkGrey05</color>
<!-- Private Mode mask icon circle fill colors -->
<color name="mozac_ui_private_mode_circle_fill" tools:ignore="UnusedResources">@color/photonPurple60</color>
</resources>

View File

@@ -372,10 +372,6 @@
<color name="mozac_feature_addons_messagebar_error_background_color" tools:ignore="UnusedResources">@color/fx_mobile_layer_color_critical</color>
<color name="mozac_feature_addons_messagebar_warning_background_color" tools:ignore="UnusedResources">@color/fx_mobile_layer_color_warning</color>
<!-- Star icon fill colors -->
<color name="mozac_ic_star_filled">@color/photonBlack</color>
<color name="mozac_ic_star_unfilled">#D9D9D9</color>
<!-- Private Mode mask icon circle fill colors -->
<color name="mozac_ui_private_mode_circle_fill" tools:ignore="UnusedResources">@color/photonPurple60</color>
</resources>

View File

@@ -397,11 +397,6 @@
<string name="pref_key_growth_resume_last_sent" translatable="false">pref_key_growth_last_resumed</string>
<string name="pref_key_growth_uri_load_last_sent" translatable="false">pref_key_growth_uri_load_last_sent</string>
<!--Shopping -->
<string name="pref_key_is_review_quality_check_enabled">pref_key_is_review_quality_check_enabled</string>
<string name="pref_key_is_review_quality_check_product_recommendations_enabled">pref_key_is_review_quality_check_product_recommendations_enabled</string>
<string name="pref_key_should_show_review_quality_opt_in_time">pref_key_should_show_review_quality_opt_in_time</string>
<!--Translations -->
<string name="pref_key_ignore_translations_data_saver_warning" translatable="false">pref_key_ignore_translations_data_saver_warning</string>

View File

@@ -26,8 +26,6 @@
<string name="app_services_abbreviation" translatable="false">AS</string>
<!-- Name for the Pocket product -->
<string name="pocket_product_name" translatable="false">Pocket</string>
<!-- Name for the Fakespot product -->
<string name="shopping_product_name" translatable="false">Fakespot</string>
<!-- App preference label for keeping the debug menu (secret settings, secret debug info, nimbus experiments, etc.) permanently revealed -->
<string name="preferences_persistent_debug_menu" translatable="false">Keep Debug Menu revealed</string>
@@ -147,10 +145,6 @@
<string name="profiler_uploaded_url_to_clipboard">URL copied to clipboard successfully</string>
<string name="review_quality_check_retailer_name_amazon">Amazon</string>
<string name="review_quality_check_retailer_name_bestbuy">Best Buy</string>
<string name="review_quality_check_retailer_name_walmart">Walmart</string>
<!-- Debug drawer "contextual feature recommendation" (CFR) tools -->
<!-- The description of the reset CFR section in CFR Tools -->
<string name="debug_drawer_cfr_tools_reset_cfr_description">Toggle off a CFR to reset it. Toggle on a CFR to mark it as shown.</string>

View File

@@ -2438,149 +2438,149 @@
<!-- Review quality check feature-->
<!-- Name for the review quality check feature used as title for the panel. -->
<string name="review_quality_check_feature_name_2">Review Checker</string>
<string name="review_quality_check_feature_name_2" moz:removedIn="136" tools:ignore="UnusedResources">Review Checker</string>
<!-- Summary for grades A and B for review quality check adjusted grading. -->
<string name="review_quality_check_grade_a_b_description">Reliable reviews</string>
<string name="review_quality_check_grade_a_b_description" moz:removedIn="136" tools:ignore="UnusedResources">Reliable reviews</string>
<!-- Summary for grade C for review quality check adjusted grading. -->
<string name="review_quality_check_grade_c_description">Mix of reliable and unreliable reviews</string>
<string name="review_quality_check_grade_c_description" moz:removedIn="136" tools:ignore="UnusedResources">Mix of reliable and unreliable reviews</string>
<!-- Summary for grades D and F for review quality check adjusted grading. -->
<string name="review_quality_check_grade_d_f_description">Unreliable reviews</string>
<string name="review_quality_check_grade_d_f_description" moz:removedIn="136" tools:ignore="UnusedResources">Unreliable reviews</string>
<!-- Text for title presenting the reliability of a product's reviews. -->
<string name="review_quality_check_grade_title">How reliable are these reviews?</string>
<string name="review_quality_check_grade_title" moz:removedIn="136" tools:ignore="UnusedResources">How reliable are these reviews?</string>
<!-- Title for when the rating has been updated by the review checker -->
<string name="review_quality_check_adjusted_rating_title">Adjusted rating</string>
<string name="review_quality_check_adjusted_rating_title" moz:removedIn="136" tools:ignore="UnusedResources">Adjusted rating</string>
<!-- Description for a product's adjusted star rating. The text presents that the product's reviews which were evaluated as unreliable were removed from the adjusted rating. -->
<string name="review_quality_check_adjusted_rating_description_2">Based on reliable reviews</string>
<string name="review_quality_check_adjusted_rating_description_2" moz:removedIn="136" tools:ignore="UnusedResources">Based on reliable reviews</string>
<!-- Title for list of highlights from a product's review emphasizing a product's important traits. -->
<string name="review_quality_check_highlights_title">Highlights from recent reviews</string>
<string name="review_quality_check_highlights_title" moz:removedIn="136" tools:ignore="UnusedResources">Highlights from recent reviews</string>
<!-- Title for section explaining how we analyze the reliability of a product's reviews. -->
<string name="review_quality_check_explanation_title">How we determine review quality</string>
<string name="review_quality_check_explanation_title" moz:removedIn="136" tools:ignore="UnusedResources">How we determine review quality</string>
<!-- Paragraph explaining how we analyze the reliability of a product's reviews. First parameter is the Fakespot product name. In the phrase "Fakespot by Mozilla", "by" can be localized. Does not need to stay by. -->
<string name="review_quality_check_explanation_body_reliability">We use AI technology from %s by Mozilla to check the reliability of product reviews. This will only help you assess review quality, not product quality. </string>
<string name="review_quality_check_explanation_body_reliability" moz:removedIn="136" tools:ignore="UnusedResources">We use AI technology from %s by Mozilla to check the reliability of product reviews. This will only help you assess review quality, not product quality. </string>
<!-- Paragraph explaining the grading system we use to classify the reliability of a product's reviews. -->
<string name="review_quality_check_info_review_grade_header"><![CDATA[We assign each products reviews a <b>letter grade</b> from A to F.]]></string>
<string name="review_quality_check_info_review_grade_header" moz:removedIn="136" tools:ignore="UnusedResources"><![CDATA[We assign each products reviews a <b>letter grade</b> from A to F.]]></string>
<!-- Description explaining grades A and B for review quality check adjusted grading. -->
<string name="review_quality_check_info_grade_info_AB">Reliable reviews. We believe the reviews are likely from real customers who left honest, unbiased reviews.</string>
<string name="review_quality_check_info_grade_info_AB" moz:removedIn="136" tools:ignore="UnusedResources">Reliable reviews. We believe the reviews are likely from real customers who left honest, unbiased reviews.</string>
<!-- Description explaining grade C for review quality check adjusted grading. -->
<string name="review_quality_check_info_grade_info_C">We believe theres a mix of reliable and unreliable reviews.</string>
<string name="review_quality_check_info_grade_info_C" moz:removedIn="136" tools:ignore="UnusedResources">We believe theres a mix of reliable and unreliable reviews.</string>
<!-- Description explaining grades D and F for review quality check adjusted grading. -->
<string name="review_quality_check_info_grade_info_DF">Unreliable reviews. We believe the reviews are likely fake or from biased reviewers.</string>
<string name="review_quality_check_info_grade_info_DF" moz:removedIn="136" tools:ignore="UnusedResources">Unreliable reviews. We believe the reviews are likely fake or from biased reviewers.</string>
<!-- Paragraph explaining how a product's adjusted grading is calculated. -->
<string name="review_quality_check_explanation_body_adjusted_grading"><![CDATA[The <b>adjusted rating</b> is based only on reviews we believe to be reliable.]]></string>
<string name="review_quality_check_explanation_body_adjusted_grading" moz:removedIn="136" tools:ignore="UnusedResources"><![CDATA[The <b>adjusted rating</b> is based only on reviews we believe to be reliable.]]></string>
<!-- Paragraph explaining product review highlights. First parameter is the name of the retailer (e.g. Amazon). -->
<string name="review_quality_check_explanation_body_highlights"><![CDATA[<b>Highlights</b> are from %s reviews within the last 80 days that we believe to be reliable.]]></string>
<string name="review_quality_check_explanation_body_highlights" moz:removedIn="136" tools:ignore="UnusedResources"><![CDATA[<b>Highlights</b> are from %s reviews within the last 80 days that we believe to be reliable.]]></string>
<!-- Text for learn more caption presenting a link with information about review quality. First parameter is for clickable text defined in review_quality_check_info_learn_more_link. -->
<string name="review_quality_check_info_learn_more">Learn more about %s.</string>
<string name="review_quality_check_info_learn_more" moz:removedIn="136" tools:ignore="UnusedResources">Learn more about %s.</string>
<!-- Clickable text that links to review quality check SuMo page. First parameter is the Fakespot product name. -->
<string name="review_quality_check_info_learn_more_link_2">how %s determines review quality</string>
<string name="review_quality_check_info_learn_more_link_2" moz:removedIn="136" tools:ignore="UnusedResources">how %s determines review quality</string>
<!-- Text for title of settings section. -->
<string name="review_quality_check_settings_title">Settings</string>
<string name="review_quality_check_settings_title" moz:removedIn="136" tools:ignore="UnusedResources">Settings</string>
<!-- Text for label for switch preference to show recommended products from review quality check settings section. -->
<string name="review_quality_check_settings_recommended_products">Show ads in review checker</string>
<string name="review_quality_check_settings_recommended_products" moz:removedIn="136" tools:ignore="UnusedResources">Show ads in review checker</string>
<!-- Description for switch preference to show recommended products from review quality check settings section. First parameter is for clickable text defined in review_quality_check_settings_recommended_products_learn_more.-->
<string name="review_quality_check_settings_recommended_products_description_2" tools:ignore="UnusedResources">Youll see occasional ads for relevant products. We only advertise products with reliable reviews. %s</string>
<string name="review_quality_check_settings_recommended_products_description_2" moz:removedIn="136" tools:ignore="UnusedResources">Youll see occasional ads for relevant products. We only advertise products with reliable reviews. %s</string>
<!-- Clickable text that links to review quality check recommended products support article. -->
<string name="review_quality_check_settings_recommended_products_learn_more" tools:ignore="UnusedResources">Learn more</string>
<string name="review_quality_check_settings_recommended_products_learn_more" moz:removedIn="136" tools:ignore="UnusedResources">Learn more</string>
<!-- Text for turning sidebar off button from review quality check settings section. -->
<string name="review_quality_check_settings_turn_off">Turn off review checker</string>
<string name="review_quality_check_settings_turn_off" moz:removedIn="136" tools:ignore="UnusedResources">Turn off review checker</string>
<!-- Text for title of recommended product section. This is displayed above a product image, suggested as an alternative to the product reviewed. -->
<string name="review_quality_check_ad_title" tools:ignore="UnusedResources">More to consider</string>
<string name="review_quality_check_ad_title" moz:removedIn="136" tools:ignore="UnusedResources">More to consider</string>
<!-- Caption for recommended product section indicating this is an ad by Fakespot. First parameter is the Fakespot product name. -->
<string name="review_quality_check_ad_caption" tools:ignore="UnusedResources">Ad by %s</string>
<string name="review_quality_check_ad_caption" moz:removedIn="136" tools:ignore="UnusedResources">Ad by %s</string>
<!-- Caption for review quality check panel. First parameter is for clickable text defined in review_quality_check_powered_by_link. -->
<string name="review_quality_check_powered_by_2">Review checker is powered by %s</string>
<string name="review_quality_check_powered_by_2" moz:removedIn="136" tools:ignore="UnusedResources">Review checker is powered by %s</string>
<!-- Clickable text that links to Fakespot.com. First parameter is the Fakespot product name. In the phrase "Fakespot by Mozilla", "by" can be localized. Does not need to stay by. -->
<string name="review_quality_check_powered_by_link" tools:ignore="UnusedResources">%s by Mozilla</string>
<string name="review_quality_check_powered_by_link" moz:removedIn="136" tools:ignore="UnusedResources">%s by Mozilla</string>
<!-- Text for title of warning card informing the user that the current analysis is outdated. -->
<string name="review_quality_check_outdated_analysis_warning_title" tools:ignore="UnusedResources">New info to check</string>
<string name="review_quality_check_outdated_analysis_warning_title" moz:removedIn="136" tools:ignore="UnusedResources">New info to check</string>
<!-- Text for button from warning card informing the user that the current analysis is outdated. Clicking this should trigger the product's re-analysis. -->
<string name="review_quality_check_outdated_analysis_warning_action" tools:ignore="UnusedResources">Check now</string>
<string name="review_quality_check_outdated_analysis_warning_action" moz:removedIn="136" tools:ignore="UnusedResources">Check now</string>
<!-- Title for warning card informing the user that the current product does not have enough reviews for a review analysis. -->
<string name="review_quality_check_no_reviews_warning_title">Not enough reviews yet</string>
<string name="review_quality_check_no_reviews_warning_title" moz:removedIn="136" tools:ignore="UnusedResources">Not enough reviews yet</string>
<!-- Text for body of warning card informing the user that the current product does not have enough reviews for a review analysis. -->
<string name="review_quality_check_no_reviews_warning_body">When this product has more reviews, well be able to check their quality.</string>
<string name="review_quality_check_no_reviews_warning_body" moz:removedIn="136" tools:ignore="UnusedResources">When this product has more reviews, well be able to check their quality.</string>
<!-- Title for warning card informing the user that the current product is currently not available. -->
<string name="review_quality_check_product_availability_warning_title">Product is not available</string>
<string name="review_quality_check_product_availability_warning_title" moz:removedIn="136" tools:ignore="UnusedResources">Product is not available</string>
<!-- Text for the body of warning card informing the user that the current product is currently not available. -->
<string name="review_quality_check_product_availability_warning_body">If you see this product is back in stock, report it and well work on checking the reviews.</string>
<string name="review_quality_check_product_availability_warning_body" moz:removedIn="136" tools:ignore="UnusedResources">If you see this product is back in stock, report it and well work on checking the reviews.</string>
<!-- Clickable text for warning card informing the user that the current product is currently not available. Clicking this should inform the server that the product is available. -->
<string name="review_quality_check_product_availability_warning_action_2">Report product is in stock</string>
<string name="review_quality_check_product_availability_warning_action_2" moz:removedIn="136" tools:ignore="UnusedResources">Report product is in stock</string>
<!-- Title for warning card informing the user that the current product's analysis is still processing. The parameter is the percentage progress (0-100%) of the analysis process (e.g. 56%). -->
<string name="review_quality_check_analysis_in_progress_warning_title_2">Checking review quality (%s)</string>
<string name="review_quality_check_analysis_in_progress_warning_title_2" moz:removedIn="136" tools:ignore="UnusedResources">Checking review quality (%s)</string>
<!-- Text for body of warning card informing the user that the current product's analysis is still processing. -->
<string name="review_quality_check_analysis_in_progress_warning_body">This could take about 60 seconds.</string>
<string name="review_quality_check_analysis_in_progress_warning_body" moz:removedIn="136" tools:ignore="UnusedResources">This could take about 60 seconds.</string>
<!-- Title for info card displayed after the user reports a product is back in stock. -->
<string name="review_quality_check_analysis_requested_info_title">Thanks for reporting!</string>
<string name="review_quality_check_analysis_requested_info_title" moz:removedIn="136" tools:ignore="UnusedResources">Thanks for reporting!</string>
<!-- Text for body of info card displayed after the user reports a product is back in stock. -->
<string name="review_quality_check_analysis_requested_info_body">We should have info about this products reviews within 24 hours. Please check back.</string>
<string name="review_quality_check_analysis_requested_info_body" moz:removedIn="136" tools:ignore="UnusedResources">We should have info about this products reviews within 24 hours. Please check back.</string>
<!-- Title for info card displayed when the user review checker while on a product that Fakespot does not analyze (e.g. gift cards, music). -->
<string name="review_quality_check_not_analyzable_info_title">We cant check these reviews</string>
<string name="review_quality_check_not_analyzable_info_title" moz:removedIn="136" tools:ignore="UnusedResources">We cant check these reviews</string>
<!-- Text for body of info card displayed when the user review checker while on a product that Fakespot does not analyze (e.g. gift cards, music). -->
<string name="review_quality_check_not_analyzable_info_body">Unfortunately, we cant check the review quality for certain types of products. For example, gift cards and streaming video, music, and games.</string>
<string name="review_quality_check_not_analyzable_info_body" moz:removedIn="136" tools:ignore="UnusedResources">Unfortunately, we cant check the review quality for certain types of products. For example, gift cards and streaming video, music, and games.</string>
<!-- Title for info card displayed when another user reported the displayed product is back in stock. -->
<string name="review_quality_check_analysis_requested_other_user_info_title" tools:ignore="UnusedResources">Info coming soon</string>
<string name="review_quality_check_analysis_requested_other_user_info_title" moz:removedIn="136" tools:ignore="UnusedResources">Info coming soon</string>
<!-- Text for body of info card displayed when another user reported the displayed product is back in stock. -->
<string name="review_quality_check_analysis_requested_other_user_info_body" tools:ignore="UnusedResources">We should have info about this products reviews within 24 hours. Please check back.</string>
<string name="review_quality_check_analysis_requested_other_user_info_body" moz:removedIn="136" tools:ignore="UnusedResources">We should have info about this products reviews within 24 hours. Please check back.</string>
<!-- Title for info card displayed to the user when analysis finished updating. -->
<string name="review_quality_check_analysis_updated_confirmation_title" tools:ignore="UnusedResources">Analysis is up to date</string>
<string name="review_quality_check_analysis_updated_confirmation_title" moz:removedIn="136" tools:ignore="UnusedResources">Analysis is up to date</string>
<!-- Text for the action button from info card displayed to the user when analysis finished updating. -->
<string name="review_quality_check_analysis_updated_confirmation_action" tools:ignore="UnusedResources">Got it</string>
<string name="review_quality_check_analysis_updated_confirmation_action" moz:removedIn="136" tools:ignore="UnusedResources">Got it</string>
<!-- Title for error card displayed to the user when an error occurred. -->
<string name="review_quality_check_generic_error_title">No info available right now</string>
<string name="review_quality_check_generic_error_title" moz:removedIn="136" tools:ignore="UnusedResources">No info available right now</string>
<!-- Text for body of error card displayed to the user when an error occurred. -->
<string name="review_quality_check_generic_error_body">Were working to resolve the issue. Please check back soon.</string>
<string name="review_quality_check_generic_error_body" moz:removedIn="136" tools:ignore="UnusedResources">Were working to resolve the issue. Please check back soon.</string>
<!-- Title for error card displayed to the user when the device is disconnected from the network. -->
<string name="review_quality_check_no_connection_title">No network connection</string>
<string name="review_quality_check_no_connection_title" moz:removedIn="136" tools:ignore="UnusedResources">No network connection</string>
<!-- Text for body of error card displayed to the user when the device is disconnected from the network. -->
<string name="review_quality_check_no_connection_body">Check your network connection and then try reloading the page.</string>
<string name="review_quality_check_no_connection_body" moz:removedIn="136" tools:ignore="UnusedResources">Check your network connection and then try reloading the page.</string>
<!-- Title for card displayed to the user for products whose reviews were not analyzed yet. -->
<string name="review_quality_check_no_analysis_title">No info about these reviews yet</string>
<string name="review_quality_check_no_analysis_title" moz:removedIn="136" tools:ignore="UnusedResources">No info about these reviews yet</string>
<!-- Text for the body of card displayed to the user for products whose reviews were not analyzed yet. -->
<string name="review_quality_check_no_analysis_body">To know whether this products reviews are reliable, check the review quality. It only takes about 60 seconds.</string>
<string name="review_quality_check_no_analysis_body" moz:removedIn="136" tools:ignore="UnusedResources">To know whether this products reviews are reliable, check the review quality. It only takes about 60 seconds.</string>
<!-- Text for button from body of card displayed to the user for products whose reviews were not analyzed yet. Clicking this should trigger a product analysis. -->
<string name="review_quality_check_no_analysis_link">Check review quality</string>
<string name="review_quality_check_no_analysis_link" moz:removedIn="136" tools:ignore="UnusedResources">Check review quality</string>
<!-- Headline for review quality check contextual onboarding card. -->
<string name="review_quality_check_contextual_onboarding_title">Try our trusted guide to product reviews</string>
<string name="review_quality_check_contextual_onboarding_title" moz:removedIn="136" tools:ignore="UnusedResources">Try our trusted guide to product reviews</string>
<!-- Description for review quality check contextual onboarding card. The first and last two parameters are for retailer names (e.g. Amazon, Walmart). The second parameter is for the name of the application (e.g. Firefox). -->
<string name="review_quality_check_contextual_onboarding_description">See how reliable product reviews are on %1$s before you buy. Review checker, an experimental feature from %2$s, is built right into the browser. It works on %3$s and %4$s, too.</string>
<string name="review_quality_check_contextual_onboarding_description" moz:removedIn="136" tools:ignore="UnusedResources">See how reliable product reviews are on %1$s before you buy. Review checker, an experimental feature from %2$s, is built right into the browser. It works on %3$s and %4$s, too.</string>
<!-- Description for review quality check contextual onboarding card. The first parameters is for retailer name (e.g. Amazon). The second parameter is for the name of the application (e.g. Firefox). -->
<string name="review_quality_check_contextual_onboarding_description_one_vendor">See how reliable product reviews are on %1$s before you buy. Review Checker, an experimental feature from %2$s, is built right into the browser.</string>
<string name="review_quality_check_contextual_onboarding_description_one_vendor" moz:removedIn="136" tools:ignore="UnusedResources">See how reliable product reviews are on %1$s before you buy. Review Checker, an experimental feature from %2$s, is built right into the browser.</string>
<!-- Paragraph presenting review quality check feature. First parameter is the Fakespot product name. Second parameter is for clickable text defined in review_quality_check_contextual_onboarding_learn_more_link. In the phrase "Fakespot by Mozilla", "by" can be localized. Does not need to stay by. -->
<string name="review_quality_check_contextual_onboarding_learn_more">Using the power of %1$s by Mozilla, we help you avoid biased and inauthentic reviews. Our AI model is always improving to protect you as you shop. %2$s</string>
<string name="review_quality_check_contextual_onboarding_learn_more" moz:removedIn="136" tools:ignore="UnusedResources">Using the power of %1$s by Mozilla, we help you avoid biased and inauthentic reviews. Our AI model is always improving to protect you as you shop. %2$s</string>
<!-- Clickable text from the contextual onboarding card that links to review quality check support article. -->
<string name="review_quality_check_contextual_onboarding_learn_more_link">Learn more</string>
<string name="review_quality_check_contextual_onboarding_learn_more_link" moz:removedIn="136" tools:ignore="UnusedResources">Learn more</string>
<!-- Caption text to be displayed in review quality check contextual onboarding card above the opt-in button. First parameter is Firefox app name, third parameter is the Fakespot product name. Second & fourth are for clickable texts defined in review_quality_check_contextual_onboarding_privacy_policy_3 and review_quality_check_contextual_onboarding_terms_use. -->
<string name="review_quality_check_contextual_onboarding_caption_4">By selecting “Yes, try it” you agree to %1$ss %2$s and %3$ss %4$s.</string>
<string name="review_quality_check_contextual_onboarding_caption_4" moz:removedIn="136" tools:ignore="UnusedResources">By selecting “Yes, try it” you agree to %1$ss %2$s and %3$ss %4$s.</string>
<!-- Clickable text from the review quality check contextual onboarding card that links to Fakespot privacy notice. -->
<string name="review_quality_check_contextual_onboarding_privacy_policy_3">privacy notice</string>
<string name="review_quality_check_contextual_onboarding_privacy_policy_3" moz:removedIn="136" tools:ignore="UnusedResources">privacy notice</string>
<!-- Clickable text from the review quality check contextual onboarding card that links to Fakespot terms of use. -->
<string name="review_quality_check_contextual_onboarding_terms_use">terms of use</string>
<string name="review_quality_check_contextual_onboarding_terms_use" moz:removedIn="136" tools:ignore="UnusedResources">terms of use</string>
<!-- Text for opt-in button from the review quality check contextual onboarding card. -->
<string name="review_quality_check_contextual_onboarding_primary_button_text">Yes, try it</string>
<string name="review_quality_check_contextual_onboarding_primary_button_text" moz:removedIn="136" tools:ignore="UnusedResources">Yes, try it</string>
<!-- Text for opt-out button from the review quality check contextual onboarding card. -->
<string name="review_quality_check_contextual_onboarding_secondary_button_text">Not now</string>
<string name="review_quality_check_contextual_onboarding_secondary_button_text" moz:removedIn="136" tools:ignore="UnusedResources">Not now</string>
<!-- Content description (not visible, for screen readers etc.) for opening browser menu button to open review quality check bottom sheet. -->
<string name="review_quality_check_open_handle_content_description">Open review checker</string>
<string name="review_quality_check_open_handle_content_description" moz:removedIn="136" tools:ignore="UnusedResources">Open review checker</string>
<!-- Content description (not visible, for screen readers etc.) for closing browser menu button to open review quality check bottom sheet. -->
<string name="review_quality_check_close_handle_content_description">Close review checker</string>
<string name="review_quality_check_close_handle_content_description" moz:removedIn="136" tools:ignore="UnusedResources">Close review checker</string>
<!-- Content description (not visible, for screen readers etc.) for review quality check star rating. First parameter is the number of stars (1-5) representing the rating. -->
<string name="review_quality_check_star_rating_content_description">%1$s out of 5 stars</string>
<string name="review_quality_check_star_rating_content_description" moz:removedIn="136" tools:ignore="UnusedResources">%1$s out of 5 stars</string>
<!-- Text for minimize button from highlights card. When clicked the highlights card should reduce its size. -->
<string name="review_quality_check_highlights_show_less">Show less</string>
<string name="review_quality_check_highlights_show_less" moz:removedIn="136" tools:ignore="UnusedResources">Show less</string>
<!-- Text for maximize button from highlights card. When clicked the highlights card should expand to its full size. -->
<string name="review_quality_check_highlights_show_more">Show more</string>
<string name="review_quality_check_highlights_show_more" moz:removedIn="136" tools:ignore="UnusedResources">Show more</string>
<!-- Text for highlights card quality category header. Reviews shown under this header should refer the product's quality. -->
<string name="review_quality_check_highlights_type_quality">Quality</string>
<string name="review_quality_check_highlights_type_quality" moz:removedIn="136" tools:ignore="UnusedResources">Quality</string>
<!-- Text for highlights card price category header. Reviews shown under this header should refer the product's price. -->
<string name="review_quality_check_highlights_type_price">Price</string>
<string name="review_quality_check_highlights_type_price" moz:removedIn="136" tools:ignore="UnusedResources">Price</string>
<!-- Text for highlights card shipping category header. Reviews shown under this header should refer the product's shipping. -->
<string name="review_quality_check_highlights_type_shipping">Shipping</string>
<string name="review_quality_check_highlights_type_shipping" moz:removedIn="136" tools:ignore="UnusedResources">Shipping</string>
<!-- Text for highlights card packaging and appearance category header. Reviews shown under this header should refer the product's packaging and appearance. -->
<string name="review_quality_check_highlights_type_packaging_appearance">Packaging and appearance</string>
<string name="review_quality_check_highlights_type_packaging_appearance" moz:removedIn="136" tools:ignore="UnusedResources">Packaging and appearance</string>
<!-- Text for highlights card competitiveness category header. Reviews shown under this header should refer the product's competitiveness. -->
<string name="review_quality_check_highlights_type_competitiveness">Competitiveness</string>
<string name="review_quality_check_highlights_type_competitiveness" moz:removedIn="136" tools:ignore="UnusedResources">Competitiveness</string>
<!-- Text that is surrounded by quotes. The parameter is the actual text that is in quotes. An example of that text could be: Excellent craftsmanship, and that is displayed as “Excellent craftsmanship”. The text comes from a buyer's review that the feature is highlighting" -->
<string name="surrounded_with_quotes">“%s”</string>
<string name="surrounded_with_quotes" moz:removedIn="136" tools:ignore="UnusedResources">“%s”</string>
<!-- Accessibility services actions labels. These will be appended to accessibility actions like "Double tap to.." but not by or applications but by services like Talkback. -->
<!-- Action label for elements that can be collapsed if interacting with them. Talkback will append this to say "Double tap to collapse". -->

View File

@@ -66,7 +66,6 @@ class BrowserToolbarCFRPresenterTest {
val privateTab = createTab(url = "", private = true)
val browserStore = createBrowserStore(tab = privateTab, selectedTabId = privateTab.id)
val settings: Settings = mockk(relaxed = true) {
every { reviewQualityCheckOptInTimeInMillis } returns System.currentTimeMillis()
every { shouldShowEraseActionCFR } returns false
every { shouldShowCookieBannersCFR } returns true
every { shouldUseCookieBannerPrivateMode } returns true
@@ -143,7 +142,6 @@ class BrowserToolbarCFRPresenterTest {
every { isTabStripEnabled() } returns false
}
val settings: Settings = mockk(relaxed = true) {
every { reviewQualityCheckOptInTimeInMillis } returns System.currentTimeMillis()
every { shouldShowEraseActionCFR } returns false
every { shouldShowCookieBannersCFR } returns false
every { shouldUseCookieBannerPrivateMode } returns false
@@ -174,7 +172,6 @@ class BrowserToolbarCFRPresenterTest {
every { isTabStripEnabled() } returns true
}
val settings: Settings = mockk(relaxed = true) {
every { reviewQualityCheckOptInTimeInMillis } returns System.currentTimeMillis()
every { shouldShowEraseActionCFR } returns false
every { shouldShowCookieBannersCFR } returns false
every { shouldUseCookieBannerPrivateMode } returns false
@@ -205,7 +202,6 @@ class BrowserToolbarCFRPresenterTest {
every { isTabStripEnabled() } returns false
}
val settings: Settings = mockk(relaxed = true) {
every { reviewQualityCheckOptInTimeInMillis } returns System.currentTimeMillis()
every { shouldShowEraseActionCFR } returns false
every { shouldShowCookieBannersCFR } returns false
every { shouldUseCookieBannerPrivateMode } returns false

View File

@@ -58,7 +58,6 @@ class RecordedNimbusContextTest {
put("install_referrer_response_utm_campaign", "")
put("install_referrer_response_utm_term", "")
put("install_referrer_response_utm_content", "")
put("is_review_checker_enabled", false)
put("android_sdk_version", Build.VERSION.SDK_INT.toString())
put("app_version", "")
put("locale", "")
@@ -103,7 +102,6 @@ class RecordedNimbusContextTest {
put("installReferrerResponseUtmTerm", "")
put("installReferrerResponseUtmContent", "")
put("isFirstRun", false)
put("isReviewCheckerEnabled", false)
put("language", "en")
put("locale", "")
put("region", "US")

View File

@@ -1,68 +0,0 @@
/* 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.shopping
import mozilla.components.concept.engine.shopping.Highlight
import mozilla.components.concept.engine.shopping.ProductAnalysis
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.HighlightsInfo
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.RecommendedProductState
object ProductAnalysisTestData {
fun productAnalysis(
productId: String? = "1",
analysisURL: String = "https://test.com",
grade: String? = "A",
adjustedRating: Double? = 4.5,
needsAnalysis: Boolean = false,
pageNotSupported: Boolean = false,
notEnoughReviews: Boolean = false,
lastAnalysisTime: Long = 0L,
deletedProductReported: Boolean = false,
deletedProduct: Boolean = false,
highlights: Highlight? = null,
): ProductAnalysis = ProductAnalysis(
productId = productId,
analysisURL = analysisURL,
grade = grade,
adjustedRating = adjustedRating,
needsAnalysis = needsAnalysis,
pageNotSupported = pageNotSupported,
notEnoughReviews = notEnoughReviews,
lastAnalysisTime = lastAnalysisTime,
deletedProductReported = deletedProductReported,
deletedProduct = deletedProduct,
highlights = highlights,
)
fun analysisPresent(
productId: String = "1",
productUrl: String = "https://test.com",
reviewGrade: ReviewQualityCheckState.Grade? = ReviewQualityCheckState.Grade.A,
adjustedRating: Float? = 4.5f,
analysisStatus: AnalysisStatus = AnalysisStatus.UpToDate,
highlightsInfo: HighlightsInfo? = null,
recommendedProductState: RecommendedProductState = RecommendedProductState.Initial,
): ProductReviewState.AnalysisPresent =
ProductReviewState.AnalysisPresent(
productId = productId,
productUrl = productUrl,
reviewGrade = reviewGrade,
adjustedRating = adjustedRating,
analysisStatus = analysisStatus,
highlightsInfo = highlightsInfo,
recommendedProductState = recommendedProductState,
)
fun noAnalysisPresent(
progress: Float = -1f,
): ProductReviewState.NoAnalysisPresent =
ProductReviewState.NoAnalysisPresent(
progress = ProductReviewState.Progress(progress),
)
}

View File

@@ -1,58 +0,0 @@
/* 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.shopping
import mozilla.components.concept.engine.shopping.ProductRecommendation
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
object ProductRecommendationTestData {
fun productRecommendation(
aid: String = "aid",
url: String = "https://test.com",
grade: String = "A",
adjustedRating: Double = 4.7,
sponsored: Boolean = true,
analysisUrl: String = "analysisUrl",
imageUrl: String = "https://imageurl.com",
name: String = "Test Product",
price: String = "100",
currency: String = "USD",
): ProductRecommendation = ProductRecommendation(
aid = aid,
url = url,
grade = grade,
adjustedRating = adjustedRating,
sponsored = sponsored,
analysisUrl = analysisUrl,
imageUrl = imageUrl,
name = name,
price = price,
currency = currency,
)
fun product(
aid: String = "aid",
url: String = "https://test.com",
reviewGrade: ReviewQualityCheckState.Grade = ReviewQualityCheckState.Grade.A,
adjustedRating: Double = 4.7,
sponsored: Boolean = true,
analysisUrl: String = "analysisUrl",
imageUrl: String = "https://imageurl.com",
name: String = "Test Product",
formattedPrice: String = "$100",
): ReviewQualityCheckState.RecommendedProductState.Product =
ReviewQualityCheckState.RecommendedProductState.Product(
aid = aid,
productUrl = url,
reviewGrade = reviewGrade,
adjustedRating = adjustedRating.toFloat(),
isSponsored = sponsored,
analysisUrl = analysisUrl,
imageUrl = imageUrl,
name = name,
formattedPrice = formattedPrice,
)
}

View File

@@ -1,95 +0,0 @@
/* 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.shopping
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.shopping.store.BottomSheetViewState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckStore
class ReviewQualityCheckBottomSheetStateFeatureTest {
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
@Test
fun `WHEN store state changes to not opted in from any other state THEN callback is invoked with half state`() {
val store = ReviewQualityCheckStore(middleware = emptyList())
var updatedState: BottomSheetViewState? = null
val tested = ReviewQualityCheckBottomSheetStateFeature(
store = store,
onRequestStateUpdate = {
updatedState = it
},
isScreenReaderEnabled = false,
)
tested.start()
store.dispatch(
ReviewQualityCheckAction.OptInCompleted(
isProductRecommendationsEnabled = true,
productRecommendationsExposure = true,
productVendor = ReviewQualityCheckState.ProductVendor.WALMART,
isHighlightsExpanded = false,
isInfoExpanded = false,
isSettingsExpanded = false,
),
).joinBlocking()
store.dispatch(ReviewQualityCheckAction.OptOutCompleted(emptyList())).joinBlocking()
assertEquals(BottomSheetViewState.HALF_VIEW, updatedState)
}
@Test
fun `WHEN store state changes to not opted in from initial state THEN callback is invoked with full state`() {
val store = ReviewQualityCheckStore(middleware = emptyList())
var updatedState: BottomSheetViewState? = null
val tested = ReviewQualityCheckBottomSheetStateFeature(
store = store,
onRequestStateUpdate = {
updatedState = it
},
isScreenReaderEnabled = false,
)
assertEquals(ReviewQualityCheckState.Initial, store.state)
tested.start()
store.dispatch(ReviewQualityCheckAction.OptOutCompleted(emptyList())).joinBlocking()
assertEquals(BottomSheetViewState.FULL_VIEW, updatedState)
}
@Test
fun `GIVEN an accessibility screen reader is enabled WHEN user opens bottom sheet THEN it is opened fully`() {
val store = ReviewQualityCheckStore(middleware = emptyList())
var updatedState: BottomSheetViewState? = null
val tested = ReviewQualityCheckBottomSheetStateFeature(
store = store,
onRequestStateUpdate = {
updatedState = it
},
isScreenReaderEnabled = true,
)
tested.start()
store.dispatch(
ReviewQualityCheckAction.OptInCompleted(
isProductRecommendationsEnabled = true,
productRecommendationsExposure = true,
productVendor = ReviewQualityCheckState.ProductVendor.WALMART,
isHighlightsExpanded = false,
isInfoExpanded = false,
isSettingsExpanded = false,
),
).joinBlocking()
assertEquals(BottomSheetViewState.FULL_VIEW, updatedState)
}
}

View File

@@ -1,557 +0,0 @@
/* 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.shopping
import kotlinx.coroutines.test.runTest
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.components.appstate.shopping.ShoppingState
import org.mozilla.fenix.shopping.fake.FakeShoppingExperienceFeature
class ReviewQualityCheckFeatureTest {
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
@Test
fun `WHEN feature is not enabled THEN callback returns false`() = runTest {
var availability: Boolean? = null
val tab = createTab(
url = "https://www.mozilla.org",
id = "test-tab",
isProductUrl = true,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = ReviewQualityCheckFeature(
appStore = AppStore(),
browserStore = BrowserStore(
initialState = browserState,
),
shoppingExperienceFeature = FakeShoppingExperienceFeature(enabled = false),
onIconVisibilityChange = {
availability = it
},
onBottomSheetStateChange = {},
onProductPageDetected = {},
)
tested.start()
testScheduler.advanceTimeBy(250)
assertFalse(availability!!)
}
@Test
fun `WHEN feature is enabled and selected tab is not a product page THEN callback returns false`() =
runTest {
var availability: Boolean? = null
val tab = createTab(
url = "https://www.mozilla.org",
id = "test-tab",
isProductUrl = false,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = ReviewQualityCheckFeature(
appStore = AppStore(),
browserStore = BrowserStore(
initialState = browserState,
),
shoppingExperienceFeature = FakeShoppingExperienceFeature(),
onIconVisibilityChange = {
availability = it
},
onBottomSheetStateChange = {},
onProductPageDetected = {},
)
tested.start()
assertFalse(availability!!)
}
@Test
fun `WHEN feature is enabled and selected tab is not yet loaded THEN callback returns false`() =
runTest {
var availability: Boolean? = null
val tab = createTab(
url = "https://www.mozilla.org",
id = "test-tab",
isProductUrl = true,
).let {
it.copy(content = it.content.copy(loading = true))
}
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = ReviewQualityCheckFeature(
appStore = AppStore(),
browserStore = BrowserStore(
initialState = browserState,
),
shoppingExperienceFeature = FakeShoppingExperienceFeature(),
onIconVisibilityChange = {
availability = it
},
onBottomSheetStateChange = {},
onProductPageDetected = {},
)
tested.start()
assertFalse(availability!!)
}
@Test
fun `WHEN feature is enabled and selected tab is a product page THEN callback returns true`() =
runTest {
var availability: Boolean? = null
val tab = createTab(
url = "https://www.mozilla.org",
id = "test-tab",
isProductUrl = true,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = ReviewQualityCheckFeature(
appStore = AppStore(),
browserStore = BrowserStore(
initialState = browserState,
),
shoppingExperienceFeature = FakeShoppingExperienceFeature(),
onIconVisibilityChange = {
availability = it
},
onBottomSheetStateChange = {},
debounceTimeoutMillis = { 0 },
onProductPageDetected = {},
)
tested.start()
assertTrue(availability!!)
}
@Test
fun `WHEN feature is enabled and selected tab is switched to a product page THEN callback returns true`() =
runTest {
var availability: Boolean? = null
val tab1 = createTab(
url = "https://www.mozilla.org",
id = "tab1",
isProductUrl = false,
)
val tab2 = createTab(
url = "https://www.shopping.org",
id = "tab2",
isProductUrl = true,
)
val browserStore = BrowserStore(
initialState = BrowserState(
tabs = listOf(tab1, tab2),
selectedTabId = tab1.id,
),
)
val tested = ReviewQualityCheckFeature(
appStore = AppStore(),
browserStore = browserStore,
shoppingExperienceFeature = FakeShoppingExperienceFeature(),
onIconVisibilityChange = {
availability = it
},
onBottomSheetStateChange = {},
debounceTimeoutMillis = { 0 },
onProductPageDetected = {},
)
tested.start()
assertFalse(availability!!)
browserStore.dispatch(TabListAction.SelectTabAction(tab2.id)).joinBlocking()
assertTrue(availability!!)
}
@Test
fun `WHEN feature is enabled and selected tab is switched to not a product page THEN callback returns false`() =
runTest {
var availability: Boolean? = null
val tab1 = createTab(
url = "https://www.shopping.org",
id = "tab1",
isProductUrl = true,
)
val tab2 = createTab(
url = "https://www.mozilla.org",
id = "tab2",
isProductUrl = false,
)
val browserStore = BrowserStore(
initialState = BrowserState(
tabs = listOf(tab1, tab2),
selectedTabId = tab1.id,
),
)
val tested = ReviewQualityCheckFeature(
appStore = AppStore(),
browserStore = browserStore,
shoppingExperienceFeature = FakeShoppingExperienceFeature(),
onIconVisibilityChange = {
availability = it
},
onBottomSheetStateChange = {},
debounceTimeoutMillis = { 0 },
onProductPageDetected = {},
)
tested.start()
assertTrue(availability!!)
browserStore.dispatch(TabListAction.SelectTabAction(tab2.id)).joinBlocking()
assertFalse(availability!!)
}
@Test
fun `WHEN feature is enabled and isProductUrl updates a lot THEN callback is only invoked when isProductUrl settles`() =
runTest {
val availability = mutableListOf<Boolean>()
val tab1 = createTab(
url = "https://www.shopping.org",
id = "tab1",
isProductUrl = false,
)
val browserStore = BrowserStore(
initialState = BrowserState(
tabs = listOf(tab1),
selectedTabId = tab1.id,
),
)
val tested = ReviewQualityCheckFeature(
appStore = AppStore(),
browserStore = browserStore,
shoppingExperienceFeature = FakeShoppingExperienceFeature(),
onIconVisibilityChange = {
availability.add(it)
},
onBottomSheetStateChange = {},
onProductPageDetected = {},
)
tested.start()
assertEquals(listOf(false), availability)
browserStore.dispatch(
ContentAction.UpdateProductUrlStateAction(
tabId = tab1.id,
isProductUrl = true,
),
).joinBlocking()
browserStore.dispatch(
ContentAction.UpdateProductUrlStateAction(
tabId = tab1.id,
isProductUrl = false,
),
).joinBlocking()
browserStore.dispatch(
ContentAction.UpdateProductUrlStateAction(
tabId = tab1.id,
isProductUrl = true,
),
).joinBlocking()
testScheduler.advanceTimeBy(250)
// The first true is never emitted because it is debounced
assertNotEquals(listOf(false, true, false, true), availability)
assertEquals(listOf(false, false, true), availability)
}
@Test
fun `WHEN feature is enabled and selected tab is switched to a product page after stop is called THEN callback is only called once with false`() =
runTest {
var availability: Boolean? = null
var availabilityCount = 0
val tab1 = createTab(
url = "https://www.mozilla.org",
id = "tab1",
isProductUrl = false,
)
val tab2 = createTab(
url = "https://www.shopping.org",
id = "tab2",
isProductUrl = true,
)
val browserStore = BrowserStore(
initialState = BrowserState(
tabs = listOf(tab1, tab2),
selectedTabId = tab1.id,
),
)
val tested = ReviewQualityCheckFeature(
appStore = AppStore(),
browserStore = browserStore,
shoppingExperienceFeature = FakeShoppingExperienceFeature(),
onIconVisibilityChange = {
availability = it
availabilityCount++
},
onBottomSheetStateChange = {},
onProductPageDetected = {},
)
tested.start()
tested.stop()
browserStore.dispatch(TabListAction.SelectTabAction(tab2.id)).joinBlocking()
assertEquals(1, availabilityCount)
assertFalse(availability!!)
}
@Test
fun `WHEN the shopping sheet is collapsed THEN the callback is called with false`() {
val appStore = AppStore(
initialState = AppState(
shoppingState = ShoppingState(shoppingSheetExpanded = true),
),
)
var isExpanded: Boolean? = null
val tested = ReviewQualityCheckFeature(
appStore = appStore,
browserStore = BrowserStore(),
shoppingExperienceFeature = FakeShoppingExperienceFeature(),
onIconVisibilityChange = {},
onBottomSheetStateChange = {
isExpanded = it
},
onProductPageDetected = {},
)
tested.start()
appStore.dispatch(ShoppingAction.ShoppingSheetStateUpdated(expanded = false)).joinBlocking()
assertFalse(isExpanded!!)
}
@Test
fun `WHEN the shopping sheet is expanded THEN the collapsed callback is called with true`() {
val appStore = AppStore(
initialState = AppState(
shoppingState = ShoppingState(shoppingSheetExpanded = false),
),
)
var isExpanded: Boolean? = null
val tested = ReviewQualityCheckFeature(
appStore = appStore,
browserStore = BrowserStore(),
shoppingExperienceFeature = FakeShoppingExperienceFeature(),
onIconVisibilityChange = {},
onBottomSheetStateChange = {
isExpanded = it
},
onProductPageDetected = {},
)
tested.start()
appStore.dispatch(ShoppingAction.ShoppingSheetStateUpdated(expanded = true)).joinBlocking()
assertTrue(isExpanded!!)
}
@Test
fun `WHEN the feature is restarted THEN first emission is collected to set the tint`() {
val appStore = AppStore(
initialState = AppState(
shoppingState = ShoppingState(shoppingSheetExpanded = false),
),
)
var isExpanded: Boolean? = null
val tested = ReviewQualityCheckFeature(
appStore = appStore,
browserStore = BrowserStore(),
shoppingExperienceFeature = FakeShoppingExperienceFeature(),
onIconVisibilityChange = {},
onBottomSheetStateChange = {
isExpanded = it
},
onProductPageDetected = {},
)
tested.start()
tested.stop()
// emulate emission
appStore.dispatch(ShoppingAction.ShoppingSheetStateUpdated(expanded = false)).joinBlocking()
tested.start()
assertFalse(isExpanded!!)
}
@Test
fun `GIVEN feature is enabled WHEN non product url accessed THEN callback not called`() {
runTest {
var invokedCounter = 0
val tab = createTab(
url = "https://www.mozilla.org",
id = "test-tab",
isProductUrl = false,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = ReviewQualityCheckFeature(
appStore = AppStore(),
browserStore = BrowserStore(
initialState = browserState,
),
shoppingExperienceFeature = FakeShoppingExperienceFeature(),
onIconVisibilityChange = {},
onBottomSheetStateChange = {},
debounceTimeoutMillis = { 0 },
onProductPageDetected = {
invokedCounter++
},
)
tested.start()
assertEquals(invokedCounter, 0)
}
}
@Test
fun `GIVEN feature is enabled WHEN product url accessed THEN callback called`() {
runTest {
var invokedCounter = 0
val tab = createTab(
url = "https://www.shopping.org",
id = "test-tab",
isProductUrl = true,
).let {
it.copy(content = it.content.copy(loading = false))
}
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = ReviewQualityCheckFeature(
appStore = AppStore(),
browserStore = BrowserStore(
initialState = browserState,
),
shoppingExperienceFeature = FakeShoppingExperienceFeature(),
onIconVisibilityChange = {},
onBottomSheetStateChange = {},
debounceTimeoutMillis = { 0 },
onProductPageDetected = {
invokedCounter++
},
)
tested.start()
assertEquals(invokedCounter, 1)
}
}
@Test
fun `GIVEN feature is disabled WHEN non product url accessed THEN callback not called`() {
runTest {
var invokedCounter = 0
val tab = createTab(
url = "https://www.mozilla.org",
id = "test-tab",
isProductUrl = false,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = ReviewQualityCheckFeature(
appStore = AppStore(),
browserStore = BrowserStore(
initialState = browserState,
),
shoppingExperienceFeature = FakeShoppingExperienceFeature(enabled = false),
onIconVisibilityChange = {},
onBottomSheetStateChange = {},
debounceTimeoutMillis = { 0 },
onProductPageDetected = {
invokedCounter++
},
)
tested.start()
assertEquals(invokedCounter, 0)
}
}
@Test
fun `GIVEN feature is disabled WHEN product url accessed THEN callback called`() {
runTest {
var invokedCounter = 0
val tab = createTab(
url = "https://www.mozilla.org",
id = "test-tab",
isProductUrl = true,
).let {
it.copy(content = it.content.copy(loading = false))
}
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = ReviewQualityCheckFeature(
appStore = AppStore(),
browserStore = BrowserStore(
initialState = browserState,
),
shoppingExperienceFeature = FakeShoppingExperienceFeature(enabled = false),
onIconVisibilityChange = {},
onBottomSheetStateChange = {},
debounceTimeoutMillis = { 0 },
onProductPageDetected = {
invokedCounter++
},
)
tested.start()
assertEquals(invokedCounter, 1)
}
}
}

View File

@@ -1,13 +0,0 @@
/* 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.shopping.fake
import org.mozilla.fenix.shopping.middleware.NetworkChecker
class FakeNetworkChecker(
private val isConnected: Boolean = true,
) : NetworkChecker {
override fun isConnected(): Boolean = isConnected
}

View File

@@ -1,22 +0,0 @@
/* 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.shopping.fake
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckPreferences
class FakeReviewQualityCheckPreferences(
private val isEnabled: Boolean = false,
private val isProductRecommendationsEnabled: Boolean? = false,
) : ReviewQualityCheckPreferences {
override suspend fun enabled(): Boolean = isEnabled
override suspend fun productRecommendationsEnabled(): Boolean? = isProductRecommendationsEnabled
override suspend fun setEnabled(isEnabled: Boolean) {
}
override suspend fun setProductRecommendationsEnabled(isEnabled: Boolean) {
}
}

View File

@@ -1,41 +0,0 @@
/* 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.shopping.fake
import mozilla.components.concept.engine.shopping.ProductAnalysis
import mozilla.components.concept.engine.shopping.ProductRecommendation
import org.mozilla.fenix.shopping.middleware.AnalysisStatusDto
import org.mozilla.fenix.shopping.middleware.AnalysisStatusProgressDto
import org.mozilla.fenix.shopping.middleware.ReportBackInStockStatusDto
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckService
class FakeReviewQualityCheckService(
private val productAnalysis: (Int) -> ProductAnalysis? = { null },
private val reanalysis: AnalysisStatusDto? = null,
private val statusProgress: () -> AnalysisStatusProgressDto? = { null },
private val productRecommendation: () -> ProductRecommendation? = { null },
private val report: ReportBackInStockStatusDto? = null,
) : ReviewQualityCheckService {
private var analysisCount = 0
override suspend fun fetchProductReview(): ProductAnalysis? {
return productAnalysis(analysisCount).also {
analysisCount++
}
}
override suspend fun reanalyzeProduct(): AnalysisStatusDto? = reanalysis
override suspend fun analysisStatus(): AnalysisStatusProgressDto? {
return statusProgress.invoke()
}
override suspend fun productRecommendation(shouldRecordAvailableTelemetry: Boolean): ProductRecommendation? {
return productRecommendation.invoke()
}
override suspend fun reportBackInStock(): ReportBackInStockStatusDto? = report
}

View File

@@ -1,21 +0,0 @@
/* 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.shopping.fake
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckTelemetryService
class FakeReviewQualityCheckTelemetryService(
private val recordClick: (String) -> Unit = {},
private val recordImpression: (String) -> Unit = {},
) : ReviewQualityCheckTelemetryService {
override suspend fun recordRecommendedProductClick(productAid: String) {
return recordClick.invoke(productAid)
}
override suspend fun recordRecommendedProductImpression(productAid: String) {
return recordImpression.invoke(productAid)
}
}

View File

@@ -1,21 +0,0 @@
/* 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.shopping.fake
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckVendorsService
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.ProductVendor
class FakeReviewQualityCheckVendorsService(
private val selectedTabUrl: String? = null,
private val productVendors: List<ProductVendor> = listOf(
ProductVendor.BEST_BUY,
ProductVendor.WALMART,
ProductVendor.AMAZON,
),
) : ReviewQualityCheckVendorsService {
override fun selectedTabUrl(): String? = selectedTabUrl
override fun productVendors(): List<ProductVendor> = productVendors
}

View File

@@ -1,19 +0,0 @@
/* 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.shopping.fake
import org.mozilla.fenix.shopping.ShoppingExperienceFeature
class FakeShoppingExperienceFeature(
private val enabled: Boolean = true,
private val productRecommendationsExposureEnabled: Boolean = true,
) : ShoppingExperienceFeature {
override val isEnabled: Boolean
get() = enabled
override val isProductRecommendationsExposureEnabled: Boolean
get() = productRecommendationsExposureEnabled
}

View File

@@ -1,313 +0,0 @@
/* 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.shopping.middleware
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
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.EngineSession
import mozilla.components.concept.engine.shopping.ProductAnalysis
import mozilla.components.concept.engine.shopping.ProductRecommendation
import mozilla.components.support.test.robolectric.testContext
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.GleanMetrics.Shopping
import org.mozilla.fenix.helpers.FenixGleanTestRule
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.shopping.ProductAnalysisTestData
import org.mozilla.fenix.shopping.ProductRecommendationTestData
@RunWith(FenixRobolectricTestRunner::class)
class DefaultReviewQualityCheckServiceTest {
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
@get:Rule
val gleanTestRule = FenixGleanTestRule(testContext)
@Test
fun `GIVEN fetch is called WHEN onResult is invoked with the expected type THEN product analysis returns the same data`() =
runTest {
val engineSession = mockk<EngineSession>()
val expected = ProductAnalysisTestData.productAnalysis()
every {
engineSession.requestProductAnalysis(any(), any(), any())
}.answers {
secondArg<(ProductAnalysis) -> Unit>().invoke(expected)
}
val tab = createTab(
url = "https://www.shopping.org/product",
id = "test-tab",
engineSession = engineSession,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
val actual = tested.fetchProductReview()
assertEquals(expected, actual)
}
@Test
fun `GIVEN fetch is called WHEN onException is invoked THEN product analysis returns null`() =
runTest {
val engineSession = mockk<EngineSession>()
every {
engineSession.requestProductAnalysis(any(), any(), any())
}.answers {
thirdArg<(Throwable) -> Unit>().invoke(RuntimeException())
}
val tab = createTab(
url = "https://www.shopping.org/product",
id = "test-tab",
engineSession = engineSession,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
assertNull(tested.fetchProductReview())
}
@Test
fun `WHEN fetch is called THEN fetch is called for the selected tab`() = runTest {
val engineSession = mockk<EngineSession>()
val expected = ProductAnalysisTestData.productAnalysis()
every {
engineSession.requestProductAnalysis(any(), any(), any())
}.answers {
secondArg<(ProductAnalysis) -> Unit>().invoke(expected)
}
val tab1 = createTab(
url = "https://www.mozilla.org",
id = "1",
)
val tab2 = createTab(
url = "https://www.shopping.org/product",
id = "2",
engineSession = engineSession,
)
val browserState = BrowserState(
tabs = listOf(tab1, tab2),
selectedTabId = tab2.id,
)
val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
val actual = tested.fetchProductReview()
assertEquals(expected, actual)
}
@Test
fun `GIVEN product recommendations is called WHEN onResult is invoked with the result THEN recommendations returns the data and exposure is called`() =
runTest {
val engineSession = mockk<EngineSession>()
val expected = ProductRecommendationTestData.productRecommendation()
val productRecommendations = listOf(expected)
every {
engineSession.requestProductRecommendations(any(), any(), any())
}.answers {
secondArg<(List<ProductRecommendation>) -> Unit>().invoke(productRecommendations)
}
val tab = createTab(
url = "https://www.shopping.org/product",
id = "test-tab",
engineSession = engineSession,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
val actual = tested.productRecommendation(false)
assertEquals(expected, actual)
assertNotNull(Shopping.adsExposure.testGetValue())
}
@Test
fun `GIVEN product recommendations is called WHEN onResult is invoked with a empty list and telemetry should be recorded THEN recommendations returns null and no ads available event is called`() =
runTest {
val engineSession = mockk<EngineSession>()
every {
engineSession.requestProductRecommendations(any(), any(), any())
}.answers {
secondArg<(List<ProductRecommendation>) -> Unit>().invoke(emptyList())
}
val tab = createTab(
url = "https://www.shopping.org/product",
id = "test-tab",
engineSession = engineSession,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
val actual = tested.productRecommendation(true)
assertNull(actual)
assertNotNull(Shopping.surfaceNoAdsAvailable.testGetValue())
}
@Test
fun `GIVEN product recommendations is called WHEN onResult is invoked with a empty list and telemetry should not be recorded THEN recommendations returns null and no ads available event is not called`() =
runTest {
val engineSession = mockk<EngineSession>()
every {
engineSession.requestProductRecommendations(any(), any(), any())
}.answers {
secondArg<(List<ProductRecommendation>) -> Unit>().invoke(emptyList())
}
val tab = createTab(
url = "https://www.shopping.org/product",
id = "test-tab",
engineSession = engineSession,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
val actual = tested.productRecommendation(false)
assertNull(actual)
assertNull(Shopping.surfaceNoAdsAvailable.testGetValue())
}
@Test
fun `GIVEN product recommendations is called WHEN onException is invoked THEN recommendations returns null`() =
runTest {
val engineSession = mockk<EngineSession>()
every {
engineSession.requestProductRecommendations(any(), any(), any())
}.answers {
thirdArg<(Throwable) -> Unit>().invoke(RuntimeException())
}
val tab = createTab(
url = "https://www.shopping.org/product",
id = "test-tab",
engineSession = engineSession,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
val actual = tested.productRecommendation(false)
assertNull(actual)
}
@Test
fun `GIVEN product recommendations is called WHEN onResult is invoked with the result THEN recommendations returns the same result without re-fetching again`() =
runTest {
val engineSession = mockk<EngineSession>()
val expected = ProductRecommendationTestData.productRecommendation()
val productRecommendations = listOf(expected)
every {
engineSession.requestProductRecommendations(any(), any(), any())
}.answers {
secondArg<(List<ProductRecommendation>) -> Unit>().invoke(productRecommendations)
}
val tab = createTab(
url = "https://www.shopping.org/product",
id = "test-tab",
engineSession = engineSession,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
tested.productRecommendation(false)
tested.productRecommendation(false)
val actual = tested.productRecommendation(false)
assertEquals(expected, actual)
verify(exactly = 1) {
engineSession.requestProductRecommendations(any(), any(), any())
}
}
@Test
fun `GIVEN product recommendations is called WHEN onResult is invoked with the empty result THEN recommendations fetches every time`() =
runTest {
val engineSession = mockk<EngineSession>()
every {
engineSession.requestProductRecommendations(any(), any(), any())
}.answers {
secondArg<(List<ProductRecommendation>) -> Unit>().invoke(emptyList())
}
val tab = createTab(
url = "https://www.shopping.org/product",
id = "test-tab",
engineSession = engineSession,
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckService(BrowserStore(browserState))
tested.productRecommendation(false)
tested.productRecommendation(false)
val actual = tested.productRecommendation(false)
assertNull(actual)
verify(exactly = 3) {
engineSession.requestProductRecommendations(any(), any(), any())
}
}
}

View File

@@ -1,204 +0,0 @@
/* 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.shopping.middleware
import kotlinx.coroutines.test.runTest
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import org.junit.Assert.assertEquals
import org.junit.Test
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.ProductVendor
class DefaultReviewQualityCheckVendorsServiceTest {
@Test
fun `WHEN selected tab is an amazon_com page THEN amazon is first in product vendors list`() =
runTest {
val tab = createTab(
url = "https://www.amazon.com/product",
id = "test-tab",
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
val actual = tested.productVendors()
val expected = listOf(
ProductVendor.AMAZON,
ProductVendor.BEST_BUY,
ProductVendor.WALMART,
)
assertEquals(expected, actual)
}
@Test
fun `WHEN selected tab is a walmart page THEN walmart is first in product vendors list`() =
runTest {
val tab = createTab(
url = "https://www.walmart.com/product",
id = "test-tab",
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
val actual = tested.productVendors()
val expected = listOf(
ProductVendor.WALMART,
ProductVendor.AMAZON,
ProductVendor.BEST_BUY,
)
assertEquals(expected, actual)
}
@Test
fun `WHEN selected tab is a best buy page THEN best buy is first in product vendors list`() =
runTest {
val tab = createTab(
url = "https://www.bestbuy.com/product",
id = "test-tab",
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
val actual = tested.productVendors()
val expected = listOf(
ProductVendor.BEST_BUY,
ProductVendor.AMAZON,
ProductVendor.WALMART,
)
assertEquals(expected, actual)
}
@Test
fun `WHEN selected tab is a not a vendor page THEN default product vendors list is returned`() =
runTest {
val tab = createTab(
url = "https://www.shopping.xyz/product",
id = "test-tab",
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
val actual = tested.productVendors()
val expected = listOf(
ProductVendor.AMAZON,
ProductVendor.BEST_BUY,
ProductVendor.WALMART,
)
assertEquals(expected, actual)
}
@Test
fun `WHEN selected tab is a not a valid uri THEN default product vendors list is returned`() =
runTest {
val tab = createTab(
url = "not a url",
id = "test-tab",
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
val actual = tested.productVendors()
val expected = listOf(
ProductVendor.AMAZON,
ProductVendor.BEST_BUY,
ProductVendor.WALMART,
)
assertEquals(expected, actual)
}
@Test
fun `WHEN selected tab is an amazon_de page THEN amazon is first in product vendors list`() =
runTest {
val tab = createTab(
url = "https://www.amazon.de/product",
id = "test-tab",
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
val actual = tested.productVendors()
val expected = listOf(ProductVendor.AMAZON)
assertEquals(expected, actual)
}
@Test
fun `WHEN selected tab is an amazon_fr page THEN amazon is first in product vendors list`() =
runTest {
val tab = createTab(
url = "https://www.amazon.fr/product",
id = "test-tab",
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
val actual = tested.productVendors()
val expected = listOf(ProductVendor.AMAZON)
assertEquals(expected, actual)
}
@Test
fun `WHEN selected tab is an amazon_in page THEN default product vendors list is returned`() =
runTest {
val tab = createTab(
url = "https://www.amazon.in/product",
id = "test-tab",
)
val browserState = BrowserState(
tabs = listOf(tab),
selectedTabId = tab.id,
)
val tested = DefaultReviewQualityCheckVendorsService(BrowserStore(browserState))
val actual = tested.productVendors()
val expected = listOf(
ProductVendor.AMAZON,
ProductVendor.BEST_BUY,
ProductVendor.WALMART,
)
assertEquals(expected, actual)
}
}

View File

@@ -1,48 +0,0 @@
/* 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.shopping.middleware
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class EnumMapperTest {
private enum class DeviceType {
PHONE,
TABLET,
WEARABLE,
OTHER_TYPE,
}
@Test
fun `GIVEN an enum WHEN a string is an enum object THEN it is mapped to the enum`() {
val phone = "phone"
val tablet = "TABLET"
val wearable = "WEARABLE"
val other = "other_type"
val otherWithSpace = "other type"
assertEquals(DeviceType.PHONE, phone.asEnumOrDefault<DeviceType>())
assertEquals(DeviceType.TABLET, tablet.asEnumOrDefault<DeviceType>())
assertEquals(DeviceType.WEARABLE, wearable.asEnumOrDefault<DeviceType>())
assertEquals(DeviceType.OTHER_TYPE, other.asEnumOrDefault<DeviceType>())
assertEquals(DeviceType.OTHER_TYPE, otherWithSpace.asEnumOrDefault<DeviceType>())
}
@Test
fun `GIVEN an enum WHEN a string is not an enum object and not default is passed THEN null is returned`() {
val input = "car"
assertNull(input.asEnumOrDefault<DeviceType>())
}
@Test
fun `GIVEN an enum WHEN a string is not an enum object and default is passed THEN default is returned`() {
val input = "car"
assertEquals(DeviceType.OTHER_TYPE, input.asEnumOrDefault(DeviceType.OTHER_TYPE))
}
}

View File

@@ -1,361 +0,0 @@
/* 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.shopping.middleware
import mozilla.components.concept.engine.shopping.Highlight
import org.junit.Assert.assertEquals
import org.junit.Test
import org.mozilla.fenix.shopping.ProductAnalysisTestData
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.HighlightType
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.HighlightsInfo
class ProductAnalysisMapperTest {
@Test
fun `WHEN ProductAnalysis has data THEN it is mapped to AnalysisPresent`() {
val actual = ProductAnalysisTestData.productAnalysis(
productId = "id1",
grade = "C",
needsAnalysis = false,
adjustedRating = 3.4,
analysisURL = "https://example.com",
highlights = Highlight(
quality = listOf("quality"),
price = listOf("price"),
shipping = listOf("shipping"),
appearance = listOf("appearance"),
competitiveness = listOf("competitiveness"),
),
).toProductReviewState()
val expected = ProductAnalysisTestData.analysisPresent(
productId = "id1",
reviewGrade = ReviewQualityCheckState.Grade.C,
analysisStatus = AnalysisStatus.UpToDate,
adjustedRating = 3.4f,
productUrl = "https://example.com",
highlightsInfo = HighlightsInfo(
mapOf(
HighlightType.QUALITY to listOf("quality"),
HighlightType.PRICE to listOf("price"),
HighlightType.SHIPPING to listOf("shipping"),
HighlightType.PACKAGING_AND_APPEARANCE to listOf("appearance"),
HighlightType.COMPETITIVENESS to listOf("competitiveness"),
),
),
)
assertEquals(expected, actual)
}
@Test
fun `WHEN ProductAnalysis has data with some missing highlights THEN it is mapped to AnalysisPresent with the non null highlights`() {
val actual = ProductAnalysisTestData.productAnalysis(
productId = "id1",
grade = "C",
needsAnalysis = true,
adjustedRating = 3.4,
analysisURL = "https://example.com",
highlights = Highlight(
quality = listOf("quality"),
price = null,
shipping = null,
appearance = listOf("appearance"),
competitiveness = listOf("competitiveness"),
),
).toProductReviewState()
val expected = ProductAnalysisTestData.analysisPresent(
productId = "id1",
reviewGrade = ReviewQualityCheckState.Grade.C,
analysisStatus = AnalysisStatus.NeedsAnalysis,
adjustedRating = 3.4f,
productUrl = "https://example.com",
highlightsInfo = HighlightsInfo(
mapOf(
HighlightType.QUALITY to listOf("quality"),
HighlightType.PACKAGING_AND_APPEARANCE to listOf("appearance"),
HighlightType.COMPETITIVENESS to listOf("competitiveness"),
),
),
)
assertEquals(expected, actual)
}
@Test
fun `WHEN ProductAnalysis has an invalid grade THEN it is mapped to AnalysisPresent with grade as null`() {
val actual = ProductAnalysisTestData.productAnalysis(
productId = "id1",
grade = "?",
needsAnalysis = false,
adjustedRating = 3.4,
analysisURL = "https://example.com",
).toProductReviewState()
val expected = ProductAnalysisTestData.analysisPresent(
productId = "id1",
reviewGrade = null,
analysisStatus = AnalysisStatus.UpToDate,
adjustedRating = 3.4f,
productUrl = "https://example.com",
)
assertEquals(expected, actual)
}
@Test
fun `WHEN product analysis is null THEN it is mapped to Error`() {
val actual = null.toProductReviewState()
val expected = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.GenericError
assertEquals(expected, actual)
}
@Test
fun `WHEN product id is null and needs analysis is true THEN it is mapped to no analysis present`() {
val actual =
ProductAnalysisTestData.productAnalysis(
productId = null,
needsAnalysis = true,
).toProductReviewState()
val expected = ReviewQualityCheckState.OptedIn.ProductReviewState.NoAnalysisPresent()
assertEquals(expected, actual)
}
@Test
fun `WHEN product id is null and needs analysis is false THEN it is mapped to no generic error`() {
val actual =
ProductAnalysisTestData.productAnalysis(
productId = null,
needsAnalysis = false,
).toProductReviewState()
val expected = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.GenericError
assertEquals(expected, actual)
}
@Test
fun `WHEN there are not enough reviews and no analysis needed THEN not enough reviews card is visible`() {
val actual = ProductAnalysisTestData.productAnalysis(
notEnoughReviews = true,
needsAnalysis = false,
).toProductReviewState()
val expected = ReviewQualityCheckState.OptedIn.ProductReviewState.Error.NotEnoughReviews
assertEquals(expected, actual)
}
@Test
fun `WHEN there are enough reviews and no analysis needed THEN it is mapped to AnalysisPresent`() {
val actual = ProductAnalysisTestData.productAnalysis(
notEnoughReviews = false,
needsAnalysis = false,
).toProductReviewState()
val expected = ProductAnalysisTestData.analysisPresent(
productId = "1",
reviewGrade = ReviewQualityCheckState.Grade.A,
analysisStatus = AnalysisStatus.UpToDate,
adjustedRating = 4.5f,
productUrl = "https://test.com",
)
assertEquals(expected, actual)
}
@Test
fun `WHEN there are not enough reviews and analysis is needed THEN it is mapped to AnalysisPresent with NEEDS_ANALYSIS status`() {
val actual = ProductAnalysisTestData.productAnalysis(
notEnoughReviews = true,
needsAnalysis = true,
).toProductReviewState()
val expected = ProductAnalysisTestData.analysisPresent(
productId = "1",
reviewGrade = ReviewQualityCheckState.Grade.A,
analysisStatus = AnalysisStatus.NeedsAnalysis,
adjustedRating = 4.5f,
productUrl = "https://test.com",
highlightsInfo = null,
)
assertEquals(expected, actual)
}
@Test
fun `WHEN grade, rating and highlights are all null THEN it is mapped to no analysis present`() {
val actual =
ProductAnalysisTestData.productAnalysis(
grade = null,
adjustedRating = null,
highlights = null,
).toProductReviewState()
val expected = ReviewQualityCheckState.OptedIn.ProductReviewState.NoAnalysisPresent()
assertEquals(expected, actual)
}
@Test
fun `WHEN only rating is available THEN it is mapped to AnalysisPresent`() {
val actual = ProductAnalysisTestData.productAnalysis(
grade = null,
adjustedRating = 3.5,
highlights = null,
).toProductReviewState()
val expected = ProductAnalysisTestData.analysisPresent(
reviewGrade = null,
adjustedRating = 3.5f,
highlightsInfo = null,
)
assertEquals(expected, actual)
}
@Test
fun `WHEN only grade is available THEN it is mapped to AnalysisPresent`() {
val actual = ProductAnalysisTestData.productAnalysis(
grade = "B",
adjustedRating = null,
highlights = null,
).toProductReviewState()
val expected = ProductAnalysisTestData.analysisPresent(
reviewGrade = ReviewQualityCheckState.Grade.B,
adjustedRating = null,
highlightsInfo = null,
)
assertEquals(expected, actual)
}
@Test
fun `WHEN only highlights are available THEN it is mapped to AnalysisPresent`() {
val actual = ProductAnalysisTestData.productAnalysis(
grade = null,
adjustedRating = null,
highlights = Highlight(
quality = listOf("quality"),
price = null,
shipping = null,
appearance = listOf("appearance"),
competitiveness = listOf("competitiveness"),
),
).toProductReviewState()
val expected = ProductAnalysisTestData.analysisPresent(
reviewGrade = null,
adjustedRating = null,
highlightsInfo = HighlightsInfo(
mapOf(
HighlightType.QUALITY to listOf("quality"),
HighlightType.PACKAGING_AND_APPEARANCE to listOf("appearance"),
HighlightType.COMPETITIVENESS to listOf("competitiveness"),
),
),
)
assertEquals(expected, actual)
}
@Test
fun `WHEN highlights and grade are available THEN it is mapped to AnalysisPresent`() {
val actual = ProductAnalysisTestData.productAnalysis(
grade = "B",
adjustedRating = null,
highlights = Highlight(
quality = listOf("quality"),
price = null,
shipping = null,
appearance = listOf("appearance"),
competitiveness = listOf("competitiveness"),
),
).toProductReviewState()
val expected = ProductAnalysisTestData.analysisPresent(
reviewGrade = ReviewQualityCheckState.Grade.B,
adjustedRating = null,
highlightsInfo = HighlightsInfo(
mapOf(
HighlightType.QUALITY to listOf("quality"),
HighlightType.PACKAGING_AND_APPEARANCE to listOf("appearance"),
HighlightType.COMPETITIVENESS to listOf("competitiveness"),
),
),
)
assertEquals(expected, actual)
}
@Test
fun `WHEN highlights and rating are available THEN it is mapped to AnalysisPresent`() {
val actual = ProductAnalysisTestData.productAnalysis(
grade = null,
adjustedRating = 3.4,
highlights = Highlight(
quality = listOf("quality"),
price = null,
shipping = null,
appearance = listOf("appearance"),
competitiveness = listOf("competitiveness"),
),
).toProductReviewState()
val expected = ProductAnalysisTestData.analysisPresent(
reviewGrade = null,
adjustedRating = 3.4f,
highlightsInfo = HighlightsInfo(
mapOf(
HighlightType.QUALITY to listOf("quality"),
HighlightType.PACKAGING_AND_APPEARANCE to listOf("appearance"),
HighlightType.COMPETITIVENESS to listOf("competitiveness"),
),
),
)
assertEquals(expected, actual)
}
@Test
fun `WHEN page not supported is true THEN it is mapped to unsupported product error `() {
val actual = ProductAnalysisTestData.productAnalysis(
pageNotSupported = true,
).toProductReviewState()
val expected =
ReviewQualityCheckState.OptedIn.ProductReviewState.Error.UnsupportedProductTypeError
assertEquals(expected, actual)
}
@Test
fun `WHEN product deleted is true and has not been reported THEN it is mapped to not available and not back in stock`() {
val actual = ProductAnalysisTestData.productAnalysis(
deletedProduct = true,
deletedProductReported = false,
).toProductReviewState()
val expected =
ReviewQualityCheckState.OptedIn.ProductReviewState.Error.ProductNotAvailable
assertEquals(expected, actual)
}
@Test
fun `WHEN product deleted is true and has been reported THEN it is mapped to not available and back in stock`() {
val actual = ProductAnalysisTestData.productAnalysis(
deletedProduct = true,
deletedProductReported = true,
).toProductReviewState()
val expected =
ReviewQualityCheckState.OptedIn.ProductReviewState.Error.ProductAlreadyReported
assertEquals(expected, actual)
}
}

View File

@@ -1,50 +0,0 @@
/* 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.shopping.middleware
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.helpers.LocaleTestRule
import org.mozilla.fenix.shopping.ProductRecommendationTestData
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import java.util.Locale
class ProductRecommendationMapperTest {
@get:Rule
val localeTestRule = LocaleTestRule(Locale.US)
@Test
fun `WHEN ProductRecommendation is null THEN it is mapped to Initial`() {
val actual = null.toRecommendedProductState()
val expected = ReviewQualityCheckState.RecommendedProductState.Initial
assertEquals(expected, actual)
}
@Test
fun `WHEN ProductRecommendation has data THEN it is mapped to product`() {
val productRecommendation = ProductRecommendationTestData.productRecommendation()
val actual = productRecommendation.toRecommendedProductState()
val expected = ProductRecommendationTestData.product()
assertEquals(expected, actual)
}
@Test
fun `WHEN ProductRecommendation has data with invalid currency code THEN it is mapped to product`() {
val productRecommendation = ProductRecommendationTestData.productRecommendation(
price = "100",
currency = "invalid",
)
val actual = productRecommendation.toRecommendedProductState()
val expected = ProductRecommendationTestData.product(
formattedPrice = "100",
)
assertEquals(expected, actual)
}
}

View File

@@ -1,59 +0,0 @@
/* 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.shopping.middleware
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RetryKtTest {
@Test
fun `WHEN predicate is false THEN return data on first attempt`() = runTest {
var count = 0
val actual = retry(predicate = { false }) {
count += 1
count
}
val expected = 1
assertEquals(expected, actual)
}
@Test
fun `WHEN predicate is true THEN retry max times`() = runTest {
var count = 0
val actual = retry(
maxRetries = 10,
predicate = { true },
) {
count += 1
count
}
val expected = 10
assertEquals(expected, actual)
}
@Test
fun `WHEN predicate changes to false from true THEN return data on that attempt`() = runTest {
var count = 0
val actual = retry(
maxRetries = 10,
predicate = { it < 5 },
) {
count += 1
count
}
val expected = 5
assertEquals(expected, actual)
}
}

View File

@@ -1,56 +0,0 @@
/* 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.shopping.middleware
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
import org.mozilla.fenix.shopping.store.ReviewQualityCheckStore
class ReviewQualityCheckNavigationMiddlewareTest {
private val sumoUrl = "https://support.mozilla.org/en-US/products/mobile"
private lateinit var store: ReviewQualityCheckStore
private lateinit var browserStore: BrowserStore
private lateinit var addTabUseCase: TabsUseCases.SelectOrAddUseCase
private lateinit var middleware: ReviewQualityCheckNavigationMiddleware
@Before
fun setup() {
browserStore = BrowserStore()
addTabUseCase = mockk(relaxed = true)
middleware = ReviewQualityCheckNavigationMiddleware(
selectOrAddUseCase = addTabUseCase,
getReviewQualityCheckSumoUrl = mockk {
every { this@mockk.invoke() } returns sumoUrl
},
)
store = ReviewQualityCheckStore(
middleware = listOf(middleware),
)
}
@Test
fun `WHEN opening an external link THEN the link should be opened in a new tab`() {
val action = ReviewQualityCheckAction.OpenExplainerLearnMoreLink
store.waitUntilIdle()
assertEquals(0, browserStore.state.tabs.size)
store.dispatch(action).joinBlocking()
store.waitUntilIdle()
verify {
addTabUseCase.invoke(sumoUrl)
}
}
}

View File

@@ -1,512 +0,0 @@
/* 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.shopping.middleware
import kotlinx.coroutines.test.runTest
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import mozilla.components.support.test.robolectric.testContext
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.GleanMetrics.Shopping
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.components.appstate.shopping.ShoppingState
import org.mozilla.fenix.helpers.FenixGleanTestRule
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.shopping.ProductAnalysisTestData
import org.mozilla.fenix.shopping.fake.FakeReviewQualityCheckTelemetryService
import org.mozilla.fenix.shopping.store.BottomSheetDismissSource
import org.mozilla.fenix.shopping.store.BottomSheetViewState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent
import org.mozilla.fenix.shopping.store.ReviewQualityCheckStore
@RunWith(FenixRobolectricTestRunner::class)
class ReviewQualityCheckTelemetryMiddlewareTest {
@get:Rule
val gleanTestRule = FenixGleanTestRule(testContext)
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
private lateinit var store: ReviewQualityCheckStore
@Before
fun setup() {
store = ReviewQualityCheckStore(
middleware = provideTelemetryMiddleware(),
)
store.waitUntilIdle()
}
@Test
fun `WHEN the user opts in the feature THEN the opt in event is recorded`() {
store.dispatch(ReviewQualityCheckAction.OptIn).joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfaceOptInAccepted.testGetValue())
}
@Test
fun `WHEN the bottom sheet is closed THEN the bottom sheet closed event is recorded`() {
store.dispatch(ReviewQualityCheckAction.BottomSheetClosed(BottomSheetDismissSource.CLICK_OUTSIDE))
.joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfaceClosed.testGetValue())
val event = Shopping.surfaceClosed.testGetValue()!!
assertEquals(1, event.size)
assertEquals(
BottomSheetDismissSource.CLICK_OUTSIDE.sourceName,
event.single().extra?.getValue("source"),
)
}
@Test
fun `WHEN the bottom sheet is displayed THEN the bottom sheet displayed event is recorded`() {
store.dispatch(ReviewQualityCheckAction.BottomSheetDisplayed(BottomSheetViewState.HALF_VIEW))
.joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfaceDisplayed.testGetValue())
val event = Shopping.surfaceDisplayed.testGetValue()!!
assertEquals(1, event.size)
assertEquals(BottomSheetViewState.HALF_VIEW.state, event.single().extra?.getValue("view"))
}
@Test
fun `WHEN the learn more link from the explainer card is clicked THEN the explainer learn more event is recorded`() {
store.dispatch(ReviewQualityCheckAction.OpenExplainerLearnMoreLink).joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfaceReviewQualityExplainerUrlClicked.testGetValue())
}
@Test
fun `WHEN the terms and conditions link from the onboarding card is clicked THEN the terms and conditions event is recorded`() {
store.dispatch(ReviewQualityCheckAction.OpenOnboardingTermsLink).joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfaceShowTermsClicked.testGetValue())
}
@Test
fun `WHEN the privacy policy link from the onboarding card is clicked THEN the privacy policy event is recorded`() {
store.dispatch(ReviewQualityCheckAction.OpenOnboardingPrivacyPolicyLink).joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfaceShowPrivacyPolicyClicked.testGetValue())
}
@Test
fun `WHEN the learn more link from the onboarding card is clicked THEN the onboarding learn more event is recorded`() {
store.dispatch(ReviewQualityCheckAction.OpenOnboardingLearnMoreLink).joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfaceLearnMoreClicked.testGetValue())
}
@Test
fun `WHEN the not now button from the onboarding card is clicked THEN the not now event is recorded`() {
store.dispatch(ReviewQualityCheckAction.NotNowClicked).joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfaceNotNowClicked.testGetValue())
}
@Test
fun `WHEN the expand button from the highlights card is clicked THEN the show more recent reviews event is recorded`() {
val tested = ReviewQualityCheckStore(
initialState = ReviewQualityCheckState.OptedIn(
productRecommendationsPreference = true,
productRecommendationsExposure = true,
productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
isHighlightsExpanded = false,
),
middleware = provideTelemetryMiddleware(),
)
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.ExpandCollapseHighlights).joinBlocking()
tested.waitUntilIdle()
assertNotNull(Shopping.surfaceShowMoreRecentReviewsClicked.testGetValue())
}
@Test
fun `WHEN the collapse button from the highlights card is clicked THEN the show more recent reviews event is not recorded`() {
val tested = ReviewQualityCheckStore(
initialState = ReviewQualityCheckState.OptedIn(
productRecommendationsPreference = true,
productRecommendationsExposure = true,
productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
isHighlightsExpanded = true,
),
middleware = provideTelemetryMiddleware(),
)
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.ExpandCollapseHighlights).joinBlocking()
tested.waitUntilIdle()
assertNull(Shopping.surfaceShowMoreRecentReviewsClicked.testGetValue())
}
@Test
fun `WHEN the expand button from the settings card is clicked THEN the settings expand event is recorded`() {
val tested = ReviewQualityCheckStore(
initialState = ReviewQualityCheckState.OptedIn(
productRecommendationsPreference = true,
productRecommendationsExposure = true,
productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
isSettingsExpanded = false,
),
middleware = provideTelemetryMiddleware(),
)
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.ExpandCollapseSettings).joinBlocking()
tested.waitUntilIdle()
assertNotNull(Shopping.surfaceExpandSettings.testGetValue())
}
@Test
fun `WHEN the collapse button from the settings card is clicked THEN the settings expand event is not recorded`() {
val tested = ReviewQualityCheckStore(
initialState = ReviewQualityCheckState.OptedIn(
productRecommendationsPreference = true,
productRecommendationsExposure = true,
productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
isSettingsExpanded = true,
),
middleware = provideTelemetryMiddleware(),
)
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.ExpandCollapseSettings).joinBlocking()
tested.waitUntilIdle()
assertNull(Shopping.surfaceExpandSettings.testGetValue())
}
@Test
fun `WHEN no analysis is present THEN the no analysis event is recorded`() {
store.dispatch(ReviewQualityCheckAction.NoAnalysisDisplayed).joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfaceNoReviewReliabilityAvailable.testGetValue())
}
@Test
fun `WHEN analyze button is clicked THEN the analyze reviews event is recorded`() {
store.dispatch(ReviewQualityCheckAction.AnalyzeProduct).joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfaceAnalyzeReviewsNoneAvailableClicked.testGetValue())
}
@Test
fun `WHEN reanalyze button is clicked THEN the reanalyze event is recorded`() {
store.dispatch(ReviewQualityCheckAction.ReanalyzeProduct).joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfaceReanalyzeClicked.testGetValue())
}
@Test
fun `WHEN back in stock button is clicked THEN the reactivate event is recorded`() {
store.dispatch(ReviewQualityCheckAction.ReportProductBackInStock).joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfaceReactivatedButtonClicked.testGetValue())
}
@Test
fun `WHEN the user is opted out after initializing the feature after THEN the onboarding displayed event is recorded`() {
store.dispatch(ReviewQualityCheckAction.OptOutCompleted(emptyList())).joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfaceOnboardingDisplayed.testGetValue())
}
@Test
fun `WHEN the user is tapped the 'Powered by Fakespot by Mozilla' link THEN the link clicked telemetry is recorded`() {
store.dispatch(ReviewQualityCheckAction.OpenPoweredByLink).joinBlocking()
store.waitUntilIdle()
assertNotNull(Shopping.surfacePoweredByFakespotLinkClicked.testGetValue())
}
@Test
fun `GIVEN a product review has been updated WHEN restore analysis is false THEN the stale analysis event is recorded`() {
val productReviewState = ProductAnalysisTestData.analysisPresent(
analysisStatus = AnalysisPresent.AnalysisStatus.NeedsAnalysis,
)
val tested = ReviewQualityCheckStore(
initialState = ReviewQualityCheckState.OptedIn(
productRecommendationsPreference = null,
productRecommendationsExposure = true,
productVendor = ReviewQualityCheckState.ProductVendor.BEST_BUY,
),
middleware = provideTelemetryMiddleware(),
)
tested.dispatch(
ReviewQualityCheckAction.UpdateProductReview(
productReviewState = productReviewState,
restoreAnalysis = false,
),
).joinBlocking()
tested.waitUntilIdle()
assertNotNull(Shopping.surfaceStaleAnalysisShown.testGetValue())
}
@Test
fun `GIVEN a product review has been updated WHEN restore analysis is true THEN the stale analysis event is not recorded`() {
val productReviewState = ProductAnalysisTestData.analysisPresent(
analysisStatus = AnalysisPresent.AnalysisStatus.NeedsAnalysis,
)
val tested = ReviewQualityCheckStore(
initialState = ReviewQualityCheckState.OptedIn(
productRecommendationsPreference = null,
productRecommendationsExposure = true,
productVendor = ReviewQualityCheckState.ProductVendor.BEST_BUY,
),
middleware = provideTelemetryMiddleware(),
)
tested.dispatch(
ReviewQualityCheckAction.UpdateProductReview(
productReviewState = productReviewState,
restoreAnalysis = true,
),
).joinBlocking()
tested.waitUntilIdle()
assertNull(Shopping.surfaceStaleAnalysisShown.testGetValue())
}
@Test
fun `GIVEN a product review has been updated WHEN it is not a stale analysis THEN the stale analysis event is not recorded`() {
val productReviewState = ProductAnalysisTestData.analysisPresent()
val tested = ReviewQualityCheckStore(
initialState = ReviewQualityCheckState.OptedIn(
productRecommendationsPreference = null,
productRecommendationsExposure = true,
productVendor = ReviewQualityCheckState.ProductVendor.BEST_BUY,
),
middleware = provideTelemetryMiddleware(),
)
tested.dispatch(
ReviewQualityCheckAction.UpdateProductReview(
productReviewState = productReviewState,
restoreAnalysis = true,
),
).joinBlocking()
tested.waitUntilIdle()
assertNull(Shopping.surfaceStaleAnalysisShown.testGetValue())
}
@Test
fun `GIVEN a recommendation impression action is dispatched WHEN app state does not contain key with tab id, product url and aid THEN ad impression telemetry probe is sent`() =
runTest {
var productViewed: String? = null
val tested = ReviewQualityCheckStore(
middleware = provideTelemetryMiddleware(
reviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService(
recordImpression = {
productViewed = it
},
),
browserState = BrowserState(
selectedTabId = "tabId",
tabs = listOf(
createTab(
id = "tabId",
url = "pdp",
),
),
),
),
)
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.RecommendedProductImpression("productId"))
.joinBlocking()
tested.waitUntilIdle()
assertNotNull(Shopping.surfaceAdsImpression.testGetValue())
assertEquals("productId", productViewed)
}
@Test
fun `WHEN recommendation impression action is dispatched many times and app state does not initially contain key with tab id, product url and aid THEN ad impression telemetry probe is sent only once`() =
runTest {
var productViewed: String? = null
var impressionCount = 0
val appStore = AppStore()
val tested = ReviewQualityCheckStore(
middleware = provideTelemetryMiddleware(
reviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService(
recordImpression = {
productViewed = it
impressionCount++
},
),
browserState = BrowserState(
selectedTabId = "tabId",
tabs = listOf(
createTab(
id = "tabId",
url = "pdp",
),
),
),
appStore = appStore,
),
)
tested.waitUntilIdle()
for (i in 1..100) {
tested.dispatch(ReviewQualityCheckAction.RecommendedProductImpression("productId"))
.joinBlocking()
tested.waitUntilIdle()
appStore.waitUntilIdle()
}
assertNotNull(Shopping.surfaceAdsImpression.testGetValue())
assertEquals("productId", productViewed)
assertEquals(1, impressionCount)
}
@Test
fun `GIVEN a recommendation impression action is dispatched WHEN app state contains key with tab id, product url and aid THEN ad impression telemetry probe is NOT sent`() =
runTest {
var productViewed: String? = null
val tested = ReviewQualityCheckStore(
middleware = provideTelemetryMiddleware(
reviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService(
recordImpression = { productViewed = it },
),
browserState = BrowserState(
selectedTabId = "tabId",
tabs = listOf(
createTab(
id = "tabId",
url = "pdp",
),
),
),
appStore = AppStore(
AppState(
shoppingState = ShoppingState(
recordedProductRecommendationImpressions = setOf(
ShoppingState.ProductRecommendationImpressionKey(
tabId = "tabId",
productUrl = "pdp",
aid = "productId",
),
),
),
),
),
),
)
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.RecommendedProductImpression("productId"))
.joinBlocking()
tested.waitUntilIdle()
assertNull(Shopping.surfaceAdsImpression.testGetValue())
assertNull(productViewed)
}
@Test
fun `WHEN a product recommendation is clicked THEN the ad clicked telemetry probe is sent`() =
runTest {
var productClicked: String? = null
val tested = ReviewQualityCheckStore(
middleware = provideTelemetryMiddleware(
reviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService(
recordClick = { productClicked = it },
),
),
)
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.RecommendedProductClick("productId", ""))
.joinBlocking()
tested.waitUntilIdle()
assertNotNull(Shopping.surfaceAdsClicked.testGetValue())
assertEquals("productId", productClicked)
}
@Test
fun `GIVEN the user has opted in WHEN the user switches product recommendations on THEN send enabled product recommendations toggled telemetry probe`() {
val tested = ReviewQualityCheckStore(
initialState = ReviewQualityCheckState.OptedIn(
productRecommendationsPreference = false,
productRecommendationsExposure = true,
productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
isHighlightsExpanded = false,
),
middleware = provideTelemetryMiddleware(),
)
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking()
tested.waitUntilIdle()
assertEquals(
"enabled",
Shopping.surfaceAdsSettingToggled.testGetValue()!!.first().extra!!["action"],
)
}
@Test
fun `GIVEN the user has opted in WHEN the user switches product recommendations off THEN send disabled product recommendations toggled telemetry probe`() {
val tested = ReviewQualityCheckStore(
initialState = ReviewQualityCheckState.OptedIn(
productRecommendationsPreference = true,
productRecommendationsExposure = true,
productVendor = ReviewQualityCheckState.ProductVendor.AMAZON,
isHighlightsExpanded = false,
),
middleware = provideTelemetryMiddleware(),
)
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking()
tested.waitUntilIdle()
assertEquals(
"disabled",
Shopping.surfaceAdsSettingToggled.testGetValue()!!.first().extra!!["action"],
)
}
private fun provideTelemetryMiddleware(
reviewQualityCheckTelemetryService: FakeReviewQualityCheckTelemetryService = FakeReviewQualityCheckTelemetryService(),
browserState: BrowserState = BrowserState(),
appStore: AppStore = AppStore(),
) = listOf(
ReviewQualityCheckTelemetryMiddleware(
reviewQualityCheckTelemetryService,
BrowserStore(browserState),
appStore,
coroutinesTestRule.scope,
),
)
}

View File

@@ -1,247 +0,0 @@
/* 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.shopping.store
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mozilla.fenix.shopping.ProductAnalysisTestData
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.HighlightsInfo
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.Progress
class ReviewQualityCheckStateTest {
@Test
fun `WHEN highlights are present THEN highlights to display in compact mode should contain first 2 highlights of the first highlight type`() {
val highlights = mapOf(
ReviewQualityCheckState.HighlightType.QUALITY to listOf(
"High quality",
"Excellent craftsmanship",
"Superior materials",
),
ReviewQualityCheckState.HighlightType.PRICE to listOf(
"Affordable prices",
"Great value for money",
"Discounted offers",
),
ReviewQualityCheckState.HighlightType.SHIPPING to listOf(
"Fast and reliable shipping",
"Free shipping options",
"Express delivery",
),
ReviewQualityCheckState.HighlightType.PACKAGING_AND_APPEARANCE to listOf(
"Elegant packaging",
"Attractive appearance",
"Beautiful design",
),
ReviewQualityCheckState.HighlightType.COMPETITIVENESS to listOf(
"Competitive pricing",
"Strong market presence",
"Unbeatable deals",
),
)
val highlightsInfo = HighlightsInfo(highlights)
val expected = mapOf(
ReviewQualityCheckState.HighlightType.QUALITY to listOf(
"High quality",
"Excellent craftsmanship",
),
)
assertEquals(expected, highlightsInfo.highlightsForCompactMode)
}
@Test
fun `WHEN only 1 highlight is present THEN highlights to display in compact mode should contain that one`() {
val highlights = mapOf(
ReviewQualityCheckState.HighlightType.PRICE to listOf(
"Affordable prices",
),
)
val highlightsInfo = HighlightsInfo(highlights)
val expected = mapOf(
ReviewQualityCheckState.HighlightType.PRICE to listOf(
"Affordable prices",
),
)
assertEquals(expected, highlightsInfo.highlightsForCompactMode)
}
@Test
fun `WHEN AnalysisPresent is created with grade, rating and highlights as null THEN exception is thrown`() {
assertThrows(IllegalArgumentException::class.java) {
ProductAnalysisTestData.analysisPresent(
reviewGrade = null,
adjustedRating = null,
highlightsInfo = null,
)
}
}
@Test
fun `WHEN AnalysisPresent is created with at least one of grade, rating and highlights as not null THEN no exception is thrown`() {
val ratingPresent = kotlin.runCatching {
ProductAnalysisTestData.analysisPresent(
reviewGrade = null,
adjustedRating = 1.2f,
highlightsInfo = null,
)
}
val gradePresent = kotlin.runCatching {
ProductAnalysisTestData.analysisPresent(
reviewGrade = ReviewQualityCheckState.Grade.A,
adjustedRating = null,
highlightsInfo = null,
)
}
val highlightsPresent = kotlin.runCatching {
ProductAnalysisTestData.analysisPresent(
reviewGrade = null,
adjustedRating = null,
highlightsInfo = HighlightsInfo(
mapOf(ReviewQualityCheckState.HighlightType.QUALITY to listOf("")),
),
)
}
assert(ratingPresent.isSuccess)
assert(gradePresent.isSuccess)
assert(highlightsPresent.isSuccess)
}
@Test
fun `WHEN AnalysisPresent has more than 2 highlights snippets THEN show more button and highlights fade are visible`() {
val highlights = mapOf(
ReviewQualityCheckState.HighlightType.QUALITY to listOf(
"High quality",
"Excellent craftsmanship",
"Superior materials",
),
ReviewQualityCheckState.HighlightType.PRICE to listOf(
"Affordable prices",
"Great value for money",
"Discounted offers",
),
ReviewQualityCheckState.HighlightType.SHIPPING to listOf(
"Fast and reliable shipping",
"Free shipping options",
"Express delivery",
),
ReviewQualityCheckState.HighlightType.PACKAGING_AND_APPEARANCE to listOf(
"Elegant packaging",
"Attractive appearance",
"Beautiful design",
),
ReviewQualityCheckState.HighlightType.COMPETITIVENESS to listOf(
"Competitive pricing",
"Strong market presence",
"Unbeatable deals",
),
)
val analysis = ProductAnalysisTestData.analysisPresent(
highlightsInfo = HighlightsInfo(highlights),
)
assertTrue(analysis.highlightsInfo!!.highlightsFadeVisible)
assertTrue(analysis.highlightsInfo!!.showMoreButtonVisible)
}
@Test
fun `WHEN AnalysisPresent has exactly 1 highlights snippet THEN show more button and highlights fade are not visible`() {
val highlights = mapOf(
ReviewQualityCheckState.HighlightType.PRICE to listOf("Affordable prices"),
)
val analysis = ProductAnalysisTestData.analysisPresent(
highlightsInfo = HighlightsInfo(highlights),
)
assertFalse(analysis.highlightsInfo!!.highlightsFadeVisible)
assertFalse(analysis.highlightsInfo!!.showMoreButtonVisible)
}
@Test
fun `WHEN AnalysisPresent has exactly 2 highlights snippets THEN show more button and highlights fade are not visible`() {
val highlights = mapOf(
ReviewQualityCheckState.HighlightType.SHIPPING to listOf(
"Fast and reliable shipping",
"Free shipping options",
),
)
val analysis = ProductAnalysisTestData.analysisPresent(
highlightsInfo = HighlightsInfo(highlights),
)
assertFalse(analysis.highlightsInfo!!.highlightsFadeVisible)
assertFalse(analysis.highlightsInfo!!.showMoreButtonVisible)
}
@Test
fun `WHEN AnalysisPresent has a single highlights section and the section has more than 2 snippets THEN show more button and highlights fade are visible`() {
val highlights = mapOf(
ReviewQualityCheckState.HighlightType.SHIPPING to listOf(
"Fast and reliable shipping",
"Free shipping options",
"Express delivery",
),
)
val analysis = ProductAnalysisTestData.analysisPresent(
highlightsInfo = HighlightsInfo(highlights),
)
assertTrue(analysis.highlightsInfo!!.highlightsFadeVisible)
assertTrue(analysis.highlightsInfo!!.showMoreButtonVisible)
}
@Test
fun `WHEN AnalysisPresent has only 1 highlight snippet for the first category and more for others THEN show more button is visible and highlights fade is not visible`() {
val highlights = mapOf(
ReviewQualityCheckState.HighlightType.QUALITY to listOf(
"High quality",
),
ReviewQualityCheckState.HighlightType.PACKAGING_AND_APPEARANCE to listOf(
"Elegant packaging",
"Attractive appearance",
"Beautiful design",
),
ReviewQualityCheckState.HighlightType.COMPETITIVENESS to listOf(
"Competitive pricing",
"Strong market presence",
"Unbeatable deals",
),
)
val analysis = ProductAnalysisTestData.analysisPresent(
highlightsInfo = HighlightsInfo(highlights),
)
assertTrue(analysis.highlightsInfo!!.showMoreButtonVisible)
assertFalse(analysis.highlightsInfo!!.highlightsFadeVisible)
}
@Test
fun `WHEN progress has a positive value THEN normalized progress should match`() {
val progress = Progress(61.6f)
assertEquals(0.616f, progress.normalizedProgress)
}
@Test
fun `WHEN no analysis is present with progress THEN normalized progress should match and progress bar is visible`() {
val analysis = ProductAnalysisTestData.noAnalysisPresent(progress = 61.6f)
assertTrue(analysis.isProgressBarVisible)
assertEquals(0.616f, analysis.progress.normalizedProgress)
}
@Test
fun `WHEN no analysis is present with negative progress THEN progress bar is not visible`() {
val analysis = ProductAnalysisTestData.noAnalysisPresent(progress = -1f)
assertFalse(analysis.isProgressBarVisible)
}
}

View File

@@ -1,31 +0,0 @@
/* 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.shopping.ui
import junit.framework.TestCase.assertEquals
import org.junit.Test
class StarRatingKtTest {
@Test
fun `GIVEN a float with a decimal THEN remove the zero if present`() {
assertEquals(3, 3.0f.removeDecimalZero())
assertEquals(4.5f, 4.5f.removeDecimalZero())
assertEquals(5, 5.0f.removeDecimalZero())
assertEquals(0.2f, 0.2f.removeDecimalZero())
assertEquals(1.00001f, 1.00001f.removeDecimalZero())
assertEquals(4.999f, 4.999f.removeDecimalZero())
}
@Test
fun `GIVEN a float with a decimal THEN round its value to the nearest half`() {
assertEquals(3.0f, 3.1f.roundToNearestHalf())
assertEquals(4.5f, 4.6f.roundToNearestHalf())
assertEquals(5.0f, 4.9f.roundToNearestHalf())
assertEquals(0.5f, 0.3f.roundToNearestHalf())
assertEquals(2.5f, 2.26f.roundToNearestHalf())
assertEquals(2.0f, 2.25f.roundToNearestHalf())
}
}