diff --git a/WooCommerce/src/androidTest/assets/mocks/mappings/blaze/blaze_campaigns.json b/WooCommerce/src/androidTest/assets/mocks/mappings/blaze/blaze_campaigns.json new file mode 100644 index 00000000000..e63228c94ca --- /dev/null +++ b/WooCommerce/src/androidTest/assets/mocks/mappings/blaze/blaze_campaigns.json @@ -0,0 +1,19 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/wpcom/v2/sites/([0-9]+)/wordads/dsp/api/v1/search/campaigns/site/([0-9]+)\\?(.*)" + }, + "response": { + "status": 200, + "jsonBody": { + "total_items": 0, + "campaigns": [], + "total_pages": 0, + "page": 1 + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive" + } + } +} diff --git a/WooCommerce/src/androidTest/assets/mocks/mappings/jetpack-blogs/wc/products/products.json b/WooCommerce/src/androidTest/assets/mocks/mappings/jetpack-blogs/wc/products/products.json index 198d7612b32..6e46e7146a0 100644 --- a/WooCommerce/src/androidTest/assets/mocks/mappings/jetpack-blogs/wc/products/products.json +++ b/WooCommerce/src/androidTest/assets/mocks/mappings/jetpack-blogs/wc/products/products.json @@ -10,8 +10,8 @@ "matches": "/wc/v3/products/(.*)" }, "query": { - "matches": "(.*)orderby\":\"title(.*)" - }, + "matches": "(.*)orderby\":\"(.*)" + }, "locale": { "matches": "(.*)" } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/MyStoreBlazeView.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/MyStoreBlazeView.kt index b0042bcdf9a..2c0d00f3599 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/MyStoreBlazeView.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/MyStoreBlazeView.kt @@ -7,12 +7,14 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card import androidx.compose.material.Divider import androidx.compose.material.Icon @@ -31,16 +33,14 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.woocommerce.android.R -import com.woocommerce.android.ui.blaze.MyStoreBlazeViewModel.MyStoreBlazeUi +import com.woocommerce.android.ui.blaze.MyStoreBlazeViewModel.MyStoreBlazeCampaignState import com.woocommerce.android.ui.blaze.campaigs.BlazeCampaignItem import com.woocommerce.android.ui.compose.component.ProductThumbnail import com.woocommerce.android.ui.compose.component.WCTextButton @Composable fun MyStoreBlazeView( - state: MyStoreBlazeUi, - onCreateCampaignClicked: () -> Unit, - onShowAllClicked: () -> Unit + state: MyStoreBlazeCampaignState ) { Card( modifier = Modifier @@ -57,14 +57,14 @@ fun MyStoreBlazeView( ) ) { BlazeCampaignHeader() - when { - state.blazeActiveCampaign != null -> BlazeCampaignItem( - campaign = state.blazeActiveCampaign, + when (state) { + is MyStoreBlazeCampaignState.Campaign -> BlazeCampaignItem( + campaign = state.campaign, onCampaignClicked = {}, modifier = Modifier.padding(top = dimensionResource(id = R.dimen.major_100)) ) - else -> { + is MyStoreBlazeCampaignState.NoCampaign -> { Text( modifier = Modifier.padding( top = dimensionResource(id = R.dimen.major_100), @@ -75,32 +75,49 @@ fun MyStoreBlazeView( ) BlazeProductItem( product = state.product, - onProductSelected = {}, + onProductSelected = state.onProductClicked, modifier = Modifier.padding(top = dimensionResource(id = R.dimen.major_100)) ) } + + else -> error("Invalid state") } } - when { - state.blazeActiveCampaign != null -> ShowAllOrCreateCampaignFooter( - onShowAllClicked, - onCreateCampaignClicked + when (state) { + is MyStoreBlazeCampaignState.Campaign -> ShowAllOrCreateCampaignFooter( + onShowAllClicked = state.onViewAllCampaignsClicked, + onCreateCampaignClicked = state.onCreateCampaignClicked + ) + + is MyStoreBlazeCampaignState.NoCampaign -> CreateCampaignFooter( + onCreateCampaignClicked = state.onCreateCampaignClicked, + modifier = Modifier.padding(top = dimensionResource(id = R.dimen.major_100)) ) - else -> CreateCampaignFooter(onCreateCampaignClicked) + else -> error("Invalid state") } } } } @Composable -private fun CreateCampaignFooter(onCreateCampaignClicked: () -> Unit) { - Divider() - WCTextButton( - modifier = Modifier.padding(start = dimensionResource(id = R.dimen.major_75)), - onClick = onCreateCampaignClicked - ) { - Text(stringResource(id = R.string.blaze_campaign_create_campaign_button)) +private fun CreateCampaignFooter( + onCreateCampaignClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier) { + Divider(Modifier.padding(start = dimensionResource(id = R.dimen.major_100))) + WCTextButton( + onClick = onCreateCampaignClicked, + contentPadding = PaddingValues( + start = dimensionResource(id = R.dimen.major_100), + end = dimensionResource(id = R.dimen.major_100), + top = ButtonDefaults.TextButtonContentPadding.calculateTopPadding(), + bottom = ButtonDefaults.TextButtonContentPadding.calculateBottomPadding(), + ) + ) { + Text(stringResource(id = R.string.blaze_campaign_create_campaign_button)) + } } } @@ -185,16 +202,14 @@ fun BlazeProductItem( @Preview(name = "mid screen", device = Devices.PIXEL_4) @Preview(name = "large screen", device = Devices.NEXUS_10) @Composable -fun MyStoreBlazeViewPreview() { +fun MyStoreBlazeViewCampaignPreview() { val product = BlazeProductUi( name = "Product name", imgUrl = "", ) MyStoreBlazeView( - state = MyStoreBlazeUi( - isVisible = true, - product = product, - blazeActiveCampaign = BlazeCampaignUi( + state = MyStoreBlazeCampaignState.Campaign( + campaign = BlazeCampaignUi( product = product, status = CampaignStatusUi.Active, stats = listOf( @@ -212,8 +227,29 @@ fun MyStoreBlazeViewPreview() { ), ), ), - ), - onCreateCampaignClicked = {}, - onShowAllClicked = {} + onCampaignClicked = {}, + onViewAllCampaignsClicked = {}, + onCreateCampaignClicked = {} + ) + ) +} + +@ExperimentalFoundationApi +@Preview(name = "dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "light", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "small screen", device = Devices.PIXEL) +@Preview(name = "mid screen", device = Devices.PIXEL_4) +@Preview(name = "large screen", device = Devices.NEXUS_10) +@Composable +fun MyStoreBlazeViewNoCampaignPreview() { + MyStoreBlazeView( + state = MyStoreBlazeCampaignState.NoCampaign( + product = BlazeProductUi( + name = "Product name", + imgUrl = "", + ), + onProductClicked = {}, + onCreateCampaignClicked = {} + ) ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/MyStoreBlazeViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/MyStoreBlazeViewModel.kt index ba4c1f1b27a..2de9e8c5ae8 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/MyStoreBlazeViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/MyStoreBlazeViewModel.kt @@ -2,57 +2,135 @@ package com.woocommerce.android.ui.blaze import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.asLiveData -import com.woocommerce.android.R.string -import com.woocommerce.android.ui.blaze.CampaignStatusUi.Active -import com.woocommerce.android.util.FeatureFlag.BLAZE_ITERATION_2 +import com.woocommerce.android.R +import com.woocommerce.android.model.Product +import com.woocommerce.android.ui.blaze.IsBlazeEnabled.BlazeFlowSource +import com.woocommerce.android.ui.products.ProductListRepository +import com.woocommerce.android.ui.products.ProductStatus +import com.woocommerce.android.util.FeatureFlag import com.woocommerce.android.viewmodel.MultiLiveEvent import com.woocommerce.android.viewmodel.ScopedViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel +import org.wordpress.android.fluxc.store.WCProductStore.ProductFilterOption +import org.wordpress.android.fluxc.store.WCProductStore.ProductSorting import javax.inject.Inject @HiltViewModel class MyStoreBlazeViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + observeMostRecentBlazeCampaign: ObserveMostRecentBlazeCampaign, + private val productListRepository: ProductListRepository, + private val isBlazeEnabled: IsBlazeEnabled ) : ScopedViewModel(savedStateHandle) { - private val _blazeCampaignState = - MutableStateFlow( - MyStoreBlazeUi( - isVisible = BLAZE_ITERATION_2.isEnabled(), + @OptIn(ExperimentalCoroutinesApi::class) + val blazeCampaignState = flow { + if (!FeatureFlag.BLAZE_ITERATION_2.isEnabled()) emit(MyStoreBlazeCampaignState.Hidden) + else { + emitAll( + observeMostRecentBlazeCampaign().flatMapLatest { + when (it) { + null -> prepareUiForNoCampaign() + else -> prepareUiForCampaign(it) + } + } + ) + } + }.asLiveData() + + private fun prepareUiForNoCampaign(): Flow { + fun launchCampaignCreation(productId: Long?) { + val url = if (productId != null) { + isBlazeEnabled.buildUrlForProduct(productId, BlazeFlowSource.MY_STORE_BANNER) + } else { + isBlazeEnabled.buildUrlForSite(BlazeFlowSource.MY_STORE_BANNER) + } + triggerEvent(LaunchBlazeCampaignCreation(url, BlazeFlowSource.MY_STORE_BANNER)) + } + + return getProducts().map { products -> + val product = products.firstOrNull() ?: return@map MyStoreBlazeCampaignState.Hidden + MyStoreBlazeCampaignState.NoCampaign( product = BlazeProductUi( - name = "Product name", - imgUrl = "https://hips.hearstapps.com/hmg-prod/images/gh-082420-ghi-best-sofas-1598293488.png", + name = product.name, + imgUrl = product.firstImageUrl.orEmpty(), ), - blazeActiveCampaign = BlazeCampaignUi( + onProductClicked = { + launchCampaignCreation(product.remoteId) + }, + onCreateCampaignClicked = { + launchCampaignCreation(if (products.size == 1) product.remoteId else null) + } + ) + } + } + + @Suppress("UNUSED_PARAMETER") + private fun prepareUiForCampaign(campaign: BlazeCampaignModel): Flow { + return flowOf( + MyStoreBlazeCampaignState.Campaign( + campaign = BlazeCampaignUi( product = BlazeProductUi( name = "Product name", imgUrl = "https://hips.hearstapps.com/hmg-prod/images/gh-082420-ghi-best-sofas-1598293488.png", ), - status = Active, + status = CampaignStatusUi.Active, stats = listOf( BlazeCampaignStat( - name = string.blaze_campaign_status_impressions, + name = R.string.blaze_campaign_status_impressions, value = 100 ), BlazeCampaignStat( - name = string.blaze_campaign_status_clicks, + name = R.string.blaze_campaign_status_clicks, value = 10 ) - ), - ) + ) + ), + onCampaignClicked = { /* TODO */ }, + onViewAllCampaignsClicked = { triggerEvent(ShowAllCampaigns) }, + onCreateCampaignClicked = { /* TODO */ } ) ) - val blazeCampaignState = _blazeCampaignState.asLiveData() + } - fun onShowAllCampaignsClicked() { - triggerEvent(ShowAllCampaigns) + private fun getProducts(): Flow> { + fun getCachedProducts() = productListRepository.getProductList( + productFilterOptions = mapOf(ProductFilterOption.STATUS to ProductStatus.PUBLISH.value), + sortType = ProductSorting.DATE_DESC, + ).filterNot { it.isSampleProduct } + return flow { + emit(getCachedProducts()) + productListRepository.fetchProductList( + productFilterOptions = mapOf(ProductFilterOption.STATUS to ProductStatus.PUBLISH.value), + sortType = ProductSorting.DATE_DESC, + ) + emit(getCachedProducts()) + } } - data class MyStoreBlazeUi( - val isVisible: Boolean, - val product: BlazeProductUi, - val blazeActiveCampaign: BlazeCampaignUi? - ) + sealed interface MyStoreBlazeCampaignState { + object Hidden : MyStoreBlazeCampaignState + data class NoCampaign( + val product: BlazeProductUi, + val onProductClicked: () -> Unit, + val onCreateCampaignClicked: () -> Unit, + ) : MyStoreBlazeCampaignState + + data class Campaign( + val campaign: BlazeCampaignUi, + val onCampaignClicked: () -> Unit, + val onViewAllCampaignsClicked: () -> Unit, + val onCreateCampaignClicked: () -> Unit, + ) : MyStoreBlazeCampaignState + } + data class LaunchBlazeCampaignCreation(val url: String, val source: BlazeFlowSource) : MultiLiveEvent.Event() object ShowAllCampaigns : MultiLiveEvent.Event() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/ObserveMostRecentBlazeCampaign.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/ObserveMostRecentBlazeCampaign.kt new file mode 100644 index 00000000000..f03dbddab41 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/blaze/ObserveMostRecentBlazeCampaign.kt @@ -0,0 +1,18 @@ +package com.woocommerce.android.ui.blaze + +import com.woocommerce.android.tools.SelectedSite +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onStart +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel +import org.wordpress.android.fluxc.store.blaze.BlazeCampaignsStore +import javax.inject.Inject + +class ObserveMostRecentBlazeCampaign @Inject constructor( + private val selectedSite: SelectedSite, + private val blazeCampaignsStore: BlazeCampaignsStore +) { + operator fun invoke(): Flow = + blazeCampaignsStore.observeMostRecentBlazeCampaign(selectedSite.get()).onStart { + blazeCampaignsStore.fetchBlazeCampaigns(selectedSite.get()) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/mystore/MyStoreFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/mystore/MyStoreFragment.kt index 68cd2112154..456c2e9bfec 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/mystore/MyStoreFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/mystore/MyStoreFragment.kt @@ -46,9 +46,11 @@ import com.woocommerce.android.ui.base.TopLevelFragment import com.woocommerce.android.ui.base.UIMessageResolver import com.woocommerce.android.ui.blaze.BlazeBanner import com.woocommerce.android.ui.blaze.BlazeBannerViewModel +import com.woocommerce.android.ui.blaze.IsBlazeEnabled.BlazeFlowSource import com.woocommerce.android.ui.blaze.IsBlazeEnabled.BlazeFlowSource.MY_STORE_BANNER import com.woocommerce.android.ui.blaze.MyStoreBlazeView import com.woocommerce.android.ui.blaze.MyStoreBlazeViewModel +import com.woocommerce.android.ui.blaze.MyStoreBlazeViewModel.MyStoreBlazeCampaignState import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground import com.woocommerce.android.ui.feedback.SurveyType import com.woocommerce.android.ui.jitm.JitmFragment @@ -253,7 +255,7 @@ class MyStoreFragment : blazeBannerViewModel.event.observe(viewLifecycleOwner) { event -> when (event) { - is BlazeBannerViewModel.OpenBlazeEvent -> openBlazeWebView(event) + is BlazeBannerViewModel.OpenBlazeEvent -> openBlazeWebView(event.url, event.source) is BlazeBannerViewModel.DismissBlazeBannerEvent -> binding.blazeBannerView.collapse() is ShowDialog -> event.showDialog() } @@ -262,15 +264,13 @@ class MyStoreFragment : private fun setUpBlazeCampaignView() { myStoreBlazeViewModel.blazeCampaignState.observe(viewLifecycleOwner) { blazeCampaignState -> - if (!blazeCampaignState.isVisible) binding.blazeCampaignView.hide() + if (blazeCampaignState is MyStoreBlazeCampaignState.Hidden) binding.blazeCampaignView.hide() else { binding.blazeCampaignView.apply { setContent { WooThemeWithBackground { MyStoreBlazeView( - state = blazeCampaignState, - onCreateCampaignClicked = { }, - onShowAllClicked = myStoreBlazeViewModel::onShowAllCampaignsClicked, + state = blazeCampaignState ) } } @@ -280,6 +280,7 @@ class MyStoreFragment : } myStoreBlazeViewModel.event.observe(viewLifecycleOwner) { event -> when (event) { + is MyStoreBlazeViewModel.LaunchBlazeCampaignCreation -> openBlazeWebView(event.url, event.source) is MyStoreBlazeViewModel.ShowAllCampaigns -> { findNavController().navigateSafely( MyStoreFragmentDirections.actionMyStoreToBlazeCampaignListFragment() @@ -289,11 +290,11 @@ class MyStoreFragment : } } - private fun openBlazeWebView(event: BlazeBannerViewModel.OpenBlazeEvent) { + private fun openBlazeWebView(url: String, source: BlazeFlowSource) { findNavController().navigateSafely( NavGraphMainDirections.actionGlobalBlazeWebViewFragment( - urlToLoad = event.url, - source = event.source + urlToLoad = url, + source = source ) ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListRepository.kt index cc127042942..0e6fdec2b25 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ProductListRepository.kt @@ -81,7 +81,8 @@ class ProductListRepository @Inject constructor( suspend fun fetchProductList( loadMore: Boolean = false, productFilterOptions: Map = emptyMap(), - excludedProductIds: List? = null + excludedProductIds: List? = null, + sortType: ProductSorting? = null ): List { loadContinuation.callAndWaitUntilTimeout(AppConstants.REQUEST_TIMEOUT) { offset = if (loadMore) offset + PRODUCT_PAGE_SIZE else 0 @@ -91,7 +92,7 @@ class ProductListRepository @Inject constructor( site = selectedSite.get(), pageSize = PRODUCT_PAGE_SIZE, offset = offset, - sorting = productSortingChoice, + sorting = sortType ?: productSortingChoice, filterOptions = productFilterOptions, excludedProductIds = excludedProductIds ) @@ -163,14 +164,15 @@ class ProductListRepository @Inject constructor( */ fun getProductList( productFilterOptions: Map = emptyMap(), - excludedProductIds: List? = null + excludedProductIds: List? = null, + sortType: ProductSorting? = null ): List { val excludedIds = excludedProductIds?.takeIf { it.isNotEmpty() } return if (selectedSite.exists()) { val wcProducts = productStore.getProducts( selectedSite.get(), filterOptions = productFilterOptions, - sortType = productSortingChoice, + sortType = sortType ?: productSortingChoice, excludedProductIds = excludedIds ) wcProducts.map { it.toAppModel() } diff --git a/build.gradle b/build.gradle index 01835a8bdaf..cf28ade3534 100644 --- a/build.gradle +++ b/build.gradle @@ -95,7 +95,7 @@ tasks.register("installGitHooks", Copy) { } ext { - fluxCVersion = '2.50.0' + fluxCVersion = 'trunk-3caf1058bd9384da8a134cee9855c92e31e7ff5f' glideVersion = '4.13.2' coilVersion = '2.1.0' constraintLayoutVersion = '1.2.0'