-
Notifications
You must be signed in to change notification settings - Fork 130
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Blaze] Logic for the MyStore's View when there is no campaign #9966
Changes from 10 commits
9ee26c1
4da4420
8330553
691a86a
47f7c06
e10c39f
6bf5d8c
a6700a5
db96c8c
229add8
e753e9e
4bb0b4d
c1318f3
89da3a7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,33 +1,84 @@ | ||
package com.woocommerce.android.ui.blaze | ||
|
||
import android.os.Parcelable | ||
import androidx.annotation.ColorRes | ||
import androidx.annotation.StringRes | ||
import androidx.lifecycle.SavedStateHandle | ||
import androidx.lifecycle.asLiveData | ||
import androidx.lifecycle.viewModelScope | ||
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 com.woocommerce.android.viewmodel.getStateFlow | ||
import dagger.hilt.android.lifecycle.HiltViewModel | ||
import kotlinx.parcelize.Parcelize | ||
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 = | ||
savedStateHandle.getStateFlow( | ||
scope = viewModelScope, | ||
initialValue = MyStoreBlazeUi( | ||
isVisible = FeatureFlag.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<MyStoreBlazeCampaignState> { | ||
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<MyStoreBlazeCampaignState> { | ||
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", | ||
|
@@ -36,32 +87,57 @@ class MyStoreBlazeViewModel @Inject constructor( | |
impressions = 100, | ||
clicks = 10, | ||
budget = 1000 | ||
) | ||
), | ||
onCampaignClicked = { /* TODO */ }, | ||
onViewAllCampaignsClicked = { /* TODO */ }, | ||
onCreateCampaignClicked = { /* TODO */ } | ||
) | ||
) | ||
val blazeCampaignState = _blazeCampaignState.asLiveData() | ||
} | ||
|
||
@Parcelize | ||
data class MyStoreBlazeUi( | ||
val isVisible: Boolean, | ||
val product: BlazeProductUi, | ||
val blazeActiveCampaign: BlazeCampaignUi? | ||
) : Parcelable | ||
private fun getProducts(): Flow<List<Product>> { | ||
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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I made the function's return as Flow to allow fetching products and updating the UI, let me know what you think about the logic. |
||
productFilterOptions = mapOf(ProductFilterOption.STATUS to ProductStatus.PUBLISH.value), | ||
sortType = ProductSorting.DATE_DESC, | ||
) | ||
emit(getCachedProducts()) | ||
} | ||
} | ||
|
||
sealed interface MyStoreBlazeCampaignState { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I changed the state to a sealed interface, this IMO simplifies the logic a bit, since there is no more duplication around the product itself, and it's also simpler for dealing when there are no active products (instead of making an additional field nullable). Please let me know if you prefer keeping it the way it was. |
||
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 | ||
} | ||
|
||
@Parcelize | ||
data class BlazeProductUi( | ||
val name: String, | ||
val imgUrl: String | ||
) : Parcelable | ||
) | ||
|
||
@Parcelize | ||
data class BlazeCampaignUi( | ||
val product: BlazeProductUi, | ||
val status: CampaignStatusUi, | ||
val impressions: Int, | ||
val clicks: Int, | ||
val budget: Int | ||
) : Parcelable | ||
) | ||
|
||
enum class CampaignStatusUi( | ||
@StringRes val statusDisplayText: Int, | ||
|
@@ -89,4 +165,6 @@ class MyStoreBlazeViewModel @Inject constructor( | |
backgroundColor = R.color.blaze_campaign_status_completed_background | ||
), | ||
} | ||
|
||
data class LaunchBlazeCampaignCreation(val url: String, val source: BlazeFlowSource) : MultiLiveEvent.Event() | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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( | ||
hichamboushaba marked this conversation as resolved.
Show resolved
Hide resolved
|
||
private val selectedSite: SelectedSite, | ||
private val blazeCampaignsStore: BlazeCampaignsStore | ||
) { | ||
operator fun invoke(): Flow<BlazeCampaignModel?> = | ||
blazeCampaignsStore.observeMostRecentBlazeCampaign(selectedSite.get()).onStart { | ||
blazeCampaignsStore.fetchBlazeCampaigns(selectedSite.get()) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@JorgeMucientes I noticed that this function was emiting multiple UI elements, so it depends on the parent Composable for defining how they will be laid out, AFAIK this is not recommended, since it's hard to predict, and maintain (any change to the parent layout will impact it, but it's not easy to figure out why).
I can't find any explicit mention of this in the official guideline, but I think it can still be inferred from the fact that Component composables need to accept a
Modifier
argument, so they need to emit a single UI element which will consume it 🤔, and I also found this lint rule in Slack's repo which protects against such usage.WDYT? do you agree with this opinion? and do you think it's worth adding an explicit mention of this to our guidelines?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just to clarify further, I updated it because I needed adding a top padding, so it was a chance to refactor it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch! We actually do have a rule for that in the guidelines, only that I missed it. In fact, now that I see this, I remember adding
fun ColumnScope.CreateCampaignFooter
, but since I wasn't making any use of the scope inside the function I had to remove it.Anyway I agree with your refactor and thanks for adding the missing
Modifie
param 👍🏼