diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/selector/VariationListHandler.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/selector/VariationListHandler.kt index f1d961e731c..30ca51f878c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/selector/VariationListHandler.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/variations/selector/VariationListHandler.kt @@ -6,23 +6,29 @@ import javax.inject.Inject class VariationListHandler @Inject constructor(private val repository: VariationSelectorRepository) { companion object { - private const val PAGE_SIZE = 10 + private const val PAGE_SIZE = 25 } private val mutex = Mutex() private var offset = 0 - private var canLoadMore = true + private var canLoadMore = false fun getVariationsFlow(productId: Long) = repository.observeVariations(productId) - fun canLoadMore(): Boolean { - return canLoadMore + suspend fun resetState() { + mutex.withLock { + offset = 0 + canLoadMore = false + } + } + + fun canLoadMore(numOfVariations: Int): Boolean { + return canLoadMore || (offset + PAGE_SIZE < numOfVariations) } suspend fun fetchVariations(productId: Long, forceRefresh: Boolean = false): Result = mutex.withLock { // Reset the offset offset = 0 - canLoadMore = true if (forceRefresh) { loadVariations(productId) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosPaginationErrorIndicator.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosPaginationErrorIndicator.kt new file mode 100644 index 00000000000..a5580858822 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/common/composeui/component/WooPosPaginationErrorIndicator.kt @@ -0,0 +1,163 @@ +package com.woocommerce.android.ui.woopos.common.composeui.component + +import androidx.compose.foundation.layout.Arrangement +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.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.woocommerce.android.R +import com.woocommerce.android.ui.woopos.common.composeui.ShadowType +import com.woocommerce.android.ui.woopos.common.composeui.WooPosCard +import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview +import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme +import com.woocommerce.android.ui.woopos.common.composeui.toAdaptivePadding +import com.woocommerce.android.ui.woopos.home.items.PaginationState +import com.woocommerce.android.ui.woopos.home.items.WooPosItem.SimpleProduct +import com.woocommerce.android.ui.woopos.home.items.WooPosItem.VariableProduct +import com.woocommerce.android.ui.woopos.home.items.WooPosItemList +import com.woocommerce.android.ui.woopos.home.items.WooPosItemsViewState + +@Composable +fun WooPosPaginationErrorIndicator( + modifier: Modifier = Modifier, + icon: Painter = painterResource(id = R.drawable.woo_pos_ic_error), + message: String, + primaryButton: Button, +) { + WooPosPaginationErrorIndicatorContent( + modifier = modifier, + icon = icon, + message = message, + primaryButton = primaryButton + ) +} + +@Composable +private fun WooPosPaginationErrorIndicatorContent( + modifier: Modifier, + icon: Painter, + message: String, + primaryButton: Button +) { + val itemContentDescription = stringResource(id = R.string.woopos_items_pagination_error_content_description) + WooPosCard( + modifier = modifier + .semantics { contentDescription = itemContentDescription }, + shape = RoundedCornerShape(8.dp), + backgroundColor = MaterialTheme.colors.surface, + elevation = 6.dp, + shadowType = ShadowType.Soft, + ) { + Row( + modifier = Modifier + .height(112.dp) + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f) + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = icon, + contentDescription = stringResource(R.string.woopos_error_icon_content_description), + tint = Color.Unspecified, + ) + Text( + text = message, + style = MaterialTheme.typography.h5, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colors.error, + modifier = Modifier.padding(start = 24.dp.toAdaptivePadding()) + ) + } + + WooPosButton( + text = primaryButton.text, + onClick = primaryButton.click, + modifier = Modifier + .weight(0.5f) + .height(50.dp) + .clip(RoundedCornerShape(16.dp)) + ) + } + } +} + +@Composable +@WooPosPreview +fun WooPosPaginationErrorScreenPreview() { + val itemsState = + WooPosItemsViewState.Content( + items = listOf( + SimpleProduct( + 1, + name = "Product 1, Product 1, Product 1, " + + "Product 1, Product 1, Product 1, Product 1, Product 1" + + "Product 1, Product 1, Product 1, Product 1, Product 1", + price = "10.0$", + imageUrl = null, + ), + SimpleProduct( + 2, + name = "Product 2", + price = "2000.00$", + imageUrl = null, + ), + VariableProduct( + 3, + name = "Product 3", + price = "2000.00$", + imageUrl = null, + numOfVariations = 20, + variationIds = listOf() + ), + ), + paginationState = PaginationState.Error, + reloadingProductsWithPullToRefresh = true, + bannerState = WooPosItemsViewState.Content.BannerState( + isBannerHiddenByUser = true, + title = R.string.woopos_banner_simple_products_only_title, + message = R.string.woopos_banner_simple_products_only_message, + icon = R.drawable.info, + ), + ) + WooPosTheme { + WooPosItemList( + state = itemsState, + listState = rememberLazyListState(), + onItemClicked = {}, + onEndOfProductsListReached = {} + ) { + WooPosPaginationErrorIndicator( + message = stringResource(id = R.string.woopos_items_pagination_error), + primaryButton = Button( + text = stringResource(id = R.string.woopos_items_pagination_load_more_label), + click = {} + ) + ) + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosBaseViewState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosBaseViewState.kt index 5e499db2a43..cc273c2336e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosBaseViewState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosBaseViewState.kt @@ -6,6 +6,12 @@ sealed class WooPosBaseViewState( interface ContentViewState { val items: List - val loadingMore: Boolean val reloadingProductsWithPullToRefresh: Boolean + val paginationState: PaginationState +} + +sealed class PaginationState { + data object None : PaginationState() + data object Loading : PaginationState() + data object Error : PaginationState() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsList.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsList.kt index ee3f075c626..ccef08f1995 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsList.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsList.kt @@ -53,11 +53,12 @@ import kotlinx.coroutines.flow.filter @OptIn(ExperimentalFoundationApi::class) @Composable -fun ItemList( +fun WooPosItemList( state: ContentViewState, listState: LazyListState, onItemClicked: (item: WooPosItem) -> Unit, onEndOfProductsListReached: () -> Unit, + onErrorWhilePaginating: @Composable () -> Unit, ) { WooPosLazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -95,11 +96,21 @@ fun ItemList( } } - if (state.loadingMore) { - item { - ItemsLoadingItem() + when (state.paginationState) { + PaginationState.Error -> { + item { + onErrorWhilePaginating() + } + } + PaginationState.Loading -> { + item { + ItemsLoadingItem() + } + } + PaginationState.None -> { } } + item { Spacer(modifier = Modifier.height(104.dp)) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsScreen.kt index 132b50dc37e..4a69af40e21 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsScreen.kt @@ -48,6 +48,7 @@ import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme import com.woocommerce.android.ui.woopos.common.composeui.component.Button import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosErrorScreen +import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosPaginationErrorIndicator import com.woocommerce.android.ui.woopos.common.composeui.toAdaptivePadding import com.woocommerce.android.ui.woopos.home.items.WooPosItem.SimpleProduct import com.woocommerce.android.ui.woopos.home.items.WooPosItem.VariableProduct @@ -164,12 +165,18 @@ private fun MainItemsList( onSimpleProductsBannerLearnMoreClicked, onSimpleProductsBannerClosed ) - ItemList( + WooPosItemList( itemsState, listState, onItemClicked, onEndOfItemListReached, - ) + ) { + ProductsPaginationError( + onRetryClicked = { + onEndOfItemListReached() + } + ) + } } } @@ -317,6 +324,17 @@ fun ProductsError(onRetryClicked: () -> Unit) { } } +@Composable +private fun ProductsPaginationError(onRetryClicked: () -> Unit) { + WooPosPaginationErrorIndicator( + message = stringResource(id = R.string.woopos_items_pagination_error), + primaryButton = Button( + text = stringResource(id = R.string.woopos_items_pagination_load_more_label), + click = onRetryClicked + ), + ) +} + @OptIn(ExperimentalMaterialApi::class) @Composable @WooPosPreview @@ -353,7 +371,63 @@ fun WooPosItemsScreenPreview(modifier: Modifier = Modifier) { imageUrl = null, ), ), - loadingMore = true, + paginationState = PaginationState.Loading, + reloadingProductsWithPullToRefresh = true, + bannerState = WooPosItemsViewState.Content.BannerState( + isBannerHiddenByUser = true, + title = R.string.woopos_banner_simple_products_only_title, + message = R.string.woopos_banner_simple_products_only_message, + icon = R.drawable.info, + ), + ) + ) + WooPosTheme { + WooPosItemsScreen( + modifier = modifier, + itemsStateFlow = productState, + listState = rememberLazyListState(), + onItemClicked = {}, + onEndOfItemListReached = {}, + onPullToRefresh = {}, + onRetryClicked = {}, + onSimpleProductsBannerClosed = {}, + onSimpleProductsBannerLearnMoreClicked = {}, + onToolbarInfoIconClicked = {}, + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +@WooPosPreview +fun WooPosItemsScreenPaginationErrorPreview(modifier: Modifier = Modifier) { + val productState = MutableStateFlow( + WooPosItemsViewState.Content( + items = listOf( + SimpleProduct( + 1, + name = "Product 1, Product 1, Product 1, " + + "Product 1, Product 1, Product 1, Product 1, Product 1" + + "Product 1, Product 1, Product 1, Product 1, Product 1", + price = "10.0$", + imageUrl = null, + ), + SimpleProduct( + 2, + name = "Product 2", + price = "2000.00$", + imageUrl = null, + ), + VariableProduct( + 3, + name = "Product 3", + price = "2000.00$", + imageUrl = null, + numOfVariations = 20, + variationIds = listOf() + ), + ), + paginationState = PaginationState.Error, reloadingProductsWithPullToRefresh = true, bannerState = WooPosItemsViewState.Content.BannerState( isBannerHiddenByUser = true, @@ -472,7 +546,6 @@ fun WooPosHomeScreenItemsWithSimpleProductsOnlyBannerPreview() { imageUrl = null, ), ), - loadingMore = false, reloadingProductsWithPullToRefresh = true, bannerState = WooPosItemsViewState.Content.BannerState( isBannerHiddenByUser = false, @@ -525,7 +598,6 @@ fun WooPosHomeScreenItemsWithInfoIconInToolbarPreview() { imageUrl = null, ), ), - loadingMore = false, reloadingProductsWithPullToRefresh = false, bannerState = WooPosItemsViewState.Content.BannerState( isBannerHiddenByUser = true, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsViewModel.kt index e04924e868c..43fe26eeb25 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsViewModel.kt @@ -183,7 +183,13 @@ class WooPosItemsViewModel @Inject constructor( result.productsResult.isSuccess -> { val products = result.productsResult.getOrThrow() if (products.isNotEmpty()) { - products.toContentState() + products.toContentState( + paginationState = if (loadMoreProductsJob?.isActive == true) { + PaginationState.Loading + } else { + PaginationState.None + } + ) } else { WooPosItemsViewState.Empty() } @@ -205,7 +211,9 @@ class WooPosItemsViewModel @Inject constructor( is WooPosItemsViewState.Empty -> state.copy(reloadingProductsWithPullToRefresh = true) } - private suspend fun List.toContentState() = WooPosItemsViewState.Content( + private suspend fun List.toContentState( + paginationState: PaginationState = PaginationState.None + ) = WooPosItemsViewState.Content( items = map { product -> if (product.isVariable()) { VariableProduct( @@ -225,7 +233,7 @@ class WooPosItemsViewModel @Inject constructor( ) } }, - loadingMore = false, + paginationState = paginationState, reloadingProductsWithPullToRefresh = false, bannerState = WooPosItemsViewState.Content.BannerState( isBannerHiddenByUser = isBannerHiddenByUser(), @@ -245,7 +253,7 @@ class WooPosItemsViewModel @Inject constructor( return } - _viewState.value = currentState.copy(loadingMore = true) + _viewState.value = currentState.copy(paginationState = PaginationState.Loading) loadMoreProductsJob?.cancel() loadMoreProductsJob = viewModelScope.launch { @@ -253,7 +261,7 @@ class WooPosItemsViewModel @Inject constructor( _viewState.value = if (result.isSuccess) { result.getOrThrow().toContentState() } else { - WooPosItemsViewState.Error() + currentState.copy(paginationState = PaginationState.Error) } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsViewState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsViewState.kt index 32b7a1c4196..f9a3ebec73c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsViewState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsViewState.kt @@ -8,8 +8,8 @@ sealed class WooPosItemsViewState( ) : WooPosBaseViewState(reloadingProductsWithPullToRefresh) { data class Content( override val items: List, - override val loadingMore: Boolean, val bannerState: BannerState, + override val paginationState: PaginationState = PaginationState.None, override val reloadingProductsWithPullToRefresh: Boolean = false ) : WooPosItemsViewState(reloadingProductsWithPullToRefresh), ContentViewState { data class BannerState( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosVariationsViewState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosVariationsViewState.kt index 58d7e35773f..4f89f6ecd98 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosVariationsViewState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosVariationsViewState.kt @@ -6,8 +6,8 @@ sealed class WooPosVariationsViewState( data class Content( override val items: List, - override val loadingMore: Boolean, override val reloadingProductsWithPullToRefresh: Boolean = false, + override val paginationState: PaginationState = PaginationState.None, ) : WooPosVariationsViewState(reloadingProductsWithPullToRefresh), ContentViewState data class Loading( diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/VariationsLRUCache.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/VariationsLRUCache.kt new file mode 100644 index 00000000000..2caac421aa6 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/VariationsLRUCache.kt @@ -0,0 +1,23 @@ +package com.woocommerce.android.ui.woopos.home.items.variations + +import android.util.LruCache +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class VariationsLRUCache(maxSize: Int) { + + private val cache = LruCache(maxSize) + private val mutex = Mutex() + + suspend fun get(key: K): V? { + return mutex.withLock { + cache.get(key) + } + } + + suspend fun put(key: K, value: V) { + mutex.withLock { + cache.put(key, value) + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsDataSource.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsDataSource.kt index 6890e9947f5..8910f1edf88 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsDataSource.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsDataSource.kt @@ -5,6 +5,10 @@ import com.woocommerce.android.ui.products.variations.selector.VariationListHand import com.woocommerce.android.util.WooLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @@ -13,30 +17,58 @@ import javax.inject.Singleton class WooPosVariationsDataSource @Inject constructor( private val handler: VariationListHandler ) { - fun getVariationsFlow(productId: Long): Flow> { - return handler.getVariationsFlow(productId) + private val variationCache = VariationsLRUCache>(maxSize = 50) + + private suspend fun getCachedVariations(productId: Long): List { + return variationCache.get(productId) ?: emptyList() + } + + private suspend fun updateCache(productId: Long, variations: List) { + variationCache.put(productId, variations) + } + + suspend fun resetState() { + handler.resetState() } - fun canLoadMore(): Boolean { - return handler.canLoadMore() + fun canLoadMore(numOfVariations: Int): Boolean { + return handler.canLoadMore(numOfVariations) } - suspend fun fetchVariations(productId: Long, forceRefresh: Boolean = true): Result { - val result = handler.fetchVariations(productId, forceRefresh = forceRefresh) - return if (result.isSuccess) { - Result.success(Unit) + fun fetchFirstPage( + productId: Long, + forceRefresh: Boolean = true + ): Flow>> = flow { + if (forceRefresh) { + updateCache(productId, emptyList()) + } + + val cachedVariations = getCachedVariations(productId) + if (cachedVariations.isNotEmpty()) { + emit(FetchResult.Cached(cachedVariations)) + } + + val result = handler.fetchVariations(productId, forceRefresh = true) + if (result.isSuccess) { + val remoteVariations = handler.getVariationsFlow(productId).firstOrNull() ?: emptyList() + updateCache(productId, remoteVariations) + emit(FetchResult.Remote(Result.success(remoteVariations))) } else { - result.logFailure() - Result.failure( - result.exceptionOrNull() ?: Exception("Unknown error while loading more variations") + emit( + FetchResult.Remote( + Result.failure( + result.exceptionOrNull() ?: Exception("Unknown error while fetching variations") + ) + ) ) } - } + }.flowOn(Dispatchers.IO) - suspend fun loadMore(productId: Long): Result = withContext(Dispatchers.IO) { + suspend fun loadMore(productId: Long): Result> = withContext(Dispatchers.IO) { val result = handler.loadMore(productId) if (result.isSuccess) { - Result.success(Unit) + val fetchedVariations = handler.getVariationsFlow(productId).first() + Result.success(fetchedVariations) } else { result.logFailure() Result.failure( @@ -51,3 +83,8 @@ private fun Result.logFailure() { val errorMessage = error?.message ?: "Unknown error" WooLog.e(WooLog.T.POS, "Loading variations failed - $errorMessage", error) } + +sealed class FetchResult { + data class Cached(val data: T) : FetchResult() + data class Remote(val result: Result) : FetchResult() +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsScreen.kt index 4a58345306c..2c668fef7d5 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsScreen.kt @@ -16,10 +16,6 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.SnackbarDuration -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -29,7 +25,6 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -37,18 +32,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.flowWithLifecycle import com.woocommerce.android.R import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview import com.woocommerce.android.ui.woopos.common.composeui.WooPosTheme import com.woocommerce.android.ui.woopos.common.composeui.component.Button import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosErrorScreen +import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosPaginationErrorIndicator import com.woocommerce.android.ui.woopos.common.composeui.toAdaptivePadding -import com.woocommerce.android.ui.woopos.home.items.ItemList import com.woocommerce.android.ui.woopos.home.items.ItemsLoadingIndicator import com.woocommerce.android.ui.woopos.home.items.WooPosItem +import com.woocommerce.android.ui.woopos.home.items.WooPosItemList import com.woocommerce.android.ui.woopos.home.items.WooPosItemNavigationData.VariableProductData import com.woocommerce.android.ui.woopos.home.items.WooPosVariationsViewState import kotlinx.coroutines.flow.MutableStateFlow @@ -60,28 +53,12 @@ fun WooPosVariationsScreen( variableProductData: VariableProductData, onBackClicked: () -> Unit ) { - val viewModel: WooPosVariationsViewModel = hiltViewModel() - val snackbarHostState = remember { SnackbarHostState() } - val lifecycleOwner = LocalLifecycleOwner.current - val paginationErrorMessage = stringResource(id = R.string.woopos_variations_screen_pagination_error) - + val viewModel: WooPosVariationsViewModel = hiltViewModel( + key = variableProductData.id.toString() + ) LaunchedEffect(variableProductData.id) { viewModel.init(variableProductData.id) } - LaunchedEffect(Unit) { - viewModel.events - .flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED) - .collect { event -> - when (event) { - is WooPosVariationsViewModel.WooPosVariationEvents.PaginationError -> { - snackbarHostState.showSnackbar( - message = paginationErrorMessage, - duration = SnackbarDuration.Short - ) - } - } - } - } val state = viewModel.viewState WooPosVariationsScreens( modifier, @@ -90,7 +67,12 @@ fun WooPosVariationsScreen( viewModel.onUIEvent(WooPosVariationsUIEvents.OnItemClicked(productId, variationId)) }, onEndOfItemListReached = { - viewModel.onUIEvent(WooPosVariationsUIEvents.EndOfItemsListReached(variableProductData.id)) + viewModel.onUIEvent( + WooPosVariationsUIEvents.EndOfItemsListReached( + variableProductData.id, + variableProductData.numOfVariations + ) + ) }, onPullToRefresh = { viewModel.onUIEvent(WooPosVariationsUIEvents.PullToRefreshTriggered(variableProductData.id)) @@ -102,7 +84,6 @@ fun WooPosVariationsScreen( }, variableProductData, state, - snackbarHostState, ) } @@ -118,93 +99,83 @@ private fun WooPosVariationsScreens( onRetryClicked: () -> Unit, variableProductData: VariableProductData, state: StateFlow, - snackbarHostState: SnackbarHostState, ) { val itemState = state.collectAsState() val pullToRefreshState = rememberPullRefreshState( itemState.value.reloadingProductsWithPullToRefresh, onPullToRefresh ) - Scaffold( - snackbarHost = { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 80.dp) - ) { - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.align(Alignment.Center) - ) - } - } + val listState = rememberLazyListState() + Box( + modifier = modifier + .fillMaxSize() + .pullRefresh(pullToRefreshState) + .padding( + start = 16.dp.toAdaptivePadding(), + end = 16.dp.toAdaptivePadding(), + top = 32.dp.toAdaptivePadding(), + bottom = 0.dp.toAdaptivePadding(), + ) ) { - Box( - modifier = modifier - .fillMaxSize() - .pullRefresh(pullToRefreshState) - .padding( - start = 16.dp.toAdaptivePadding(), - end = 16.dp.toAdaptivePadding(), - top = 30.dp.toAdaptivePadding(), - bottom = 0.dp.toAdaptivePadding(), - ) + BackHandler(onBack = onBackClicked) + Column( + modifier = modifier.fillMaxHeight() ) { - BackHandler(onBack = onBackClicked) - Column( - modifier = modifier.fillMaxHeight() - ) { - VariationsToolbar( - variableProductData = variableProductData, - onBackClicked = onBackClicked - ) - when (val itemsState = itemState.value) { - is WooPosVariationsViewState.Content -> { - Spacer(modifier = Modifier.height(16.dp)) - ItemList( - state = itemsState, - listState = rememberLazyListState(), - onItemClicked = { - onItemClicked( - (it as WooPosItem.Variation).productId, - it.id - ) + VariationsToolbar( + variableProductData = variableProductData, + onBackClicked = onBackClicked + ) + when (val itemsState = itemState.value) { + is WooPosVariationsViewState.Content -> { + Spacer(modifier = Modifier.height(16.dp)) + WooPosItemList( + state = itemsState, + listState = listState, + onItemClicked = { + onItemClicked( + (it as WooPosItem.Variation).productId, + it.id + ) + }, + onEndOfProductsListReached = onEndOfItemListReached, + onErrorWhilePaginating = { + VariationsPaginationError { + onEndOfItemListReached() } - ) { - onEndOfItemListReached() } - } - - is WooPosVariationsViewState.Loading -> ItemsLoadingIndicator( - minOf(10, variableProductData.numOfVariations) ) + } - is WooPosVariationsViewState.Error -> { - VariationsError { - onRetryClicked() - } - } + is WooPosVariationsViewState.Loading -> { + Spacer(modifier = Modifier.height(16.dp)) + ItemsLoadingIndicator() + } - else -> {} + is WooPosVariationsViewState.Error -> { + VariationsError(modifier = Modifier.width(640.dp)) { + onRetryClicked() + } } + + else -> {} } - PullRefreshIndicator( - modifier = Modifier.align(Alignment.TopCenter), - refreshing = itemState.value.reloadingProductsWithPullToRefresh, - state = pullToRefreshState - ) } + PullRefreshIndicator( + modifier = Modifier.align(Alignment.TopCenter), + refreshing = itemState.value.reloadingProductsWithPullToRefresh, + state = pullToRefreshState + ) } } @Composable -fun VariationsError(onRetryClicked: () -> Unit) { +fun VariationsError(modifier: Modifier, onRetryClicked: () -> Unit) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center, ) { WooPosErrorScreen( - modifier = Modifier.width(640.dp), + modifier = modifier, message = stringResource(id = R.string.woopos_variations_loading_error_title), reason = stringResource(id = R.string.woopos_products_loading_error_message), primaryButton = Button( @@ -215,6 +186,17 @@ fun VariationsError(onRetryClicked: () -> Unit) { } } +@Composable +fun VariationsPaginationError(onRetryClicked: () -> Unit) { + WooPosPaginationErrorIndicator( + message = stringResource(id = R.string.woopos_items_pagination_error), + primaryButton = Button( + text = stringResource(id = R.string.woopos_items_pagination_load_more_label), + click = onRetryClicked + ), + ) +} + @Composable private fun VariationsToolbar( variableProductData: VariableProductData, @@ -298,7 +280,6 @@ fun WooPosVariationsScreenPreview() { imageUrl = null, ), ), - loadingMore = false, reloadingProductsWithPullToRefresh = true, ) ) @@ -316,7 +297,6 @@ fun WooPosVariationsScreenPreview() { numOfVariations = 20, ), state = productState, - snackbarHostState = SnackbarHostState() ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsUIEvents.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsUIEvents.kt index 322ae5460f3..7d3eae2b25f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsUIEvents.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsUIEvents.kt @@ -1,7 +1,7 @@ package com.woocommerce.android.ui.woopos.home.items.variations sealed class WooPosVariationsUIEvents { - data class EndOfItemsListReached(val productId: Long) : WooPosVariationsUIEvents() + data class EndOfItemsListReached(val productId: Long, val numOfVariations: Int) : WooPosVariationsUIEvents() data class PullToRefreshTriggered(val productId: Long) : WooPosVariationsUIEvents() data class VariationsLoadingErrorRetryButtonClicked(val productId: Long) : WooPosVariationsUIEvents() data class OnItemClicked(val productId: Long, val variationId: Long) : WooPosVariationsUIEvents() diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsViewModel.kt index fa0348ba426..3349356f1ba 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/variations/WooPosVariationsViewModel.kt @@ -1,21 +1,22 @@ package com.woocommerce.android.ui.woopos.home.items.variations +import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.woocommerce.android.model.ProductVariation import com.woocommerce.android.ui.woopos.common.data.WooPosGetProductById import com.woocommerce.android.ui.woopos.home.ChildToParentEvent import com.woocommerce.android.ui.woopos.home.WooPosChildrenToParentEventSender +import com.woocommerce.android.ui.woopos.home.items.PaginationState import com.woocommerce.android.ui.woopos.home.items.WooPosItem import com.woocommerce.android.ui.woopos.home.items.WooPosItemsViewModel import com.woocommerce.android.ui.woopos.home.items.WooPosVariationsViewState import com.woocommerce.android.ui.woopos.util.format.WooPosFormatPrice import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -37,53 +38,97 @@ class WooPosVariationsViewModel @Inject constructor( initialValue = _viewState.value, ) - private val _events: MutableSharedFlow = MutableSharedFlow( - extraBufferCapacity = 1 - ) - val events = _events.asSharedFlow() - private var fetchJob: Job? = null - private var loadMoreJob: Job? = null + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var loadMoreJob: Job? = null fun init(productId: Long) { - fetchVariations(productId = productId, withPullToRefresh = false, withCart = true) + viewModelScope.launch { + variationsDataSource.resetState() + } + loadVariations( + productId = productId, + withPullToRefresh = false, + withCart = true, + forceRefresh = false + ) } - private fun fetchVariations(productId: Long, withPullToRefresh: Boolean, withCart: Boolean) { - _viewState.value = if (withPullToRefresh) { - buildProductsReloadingState() - } else { - WooPosVariationsViewState.Loading(withCart = withCart) - } + private fun loadVariations( + productId: Long, + forceRefresh: Boolean, + withPullToRefresh: Boolean, + withCart: Boolean, + ) { fetchJob?.cancel() - fetchJob = viewModelScope.launch { - val product = getProductById(productId) - - val result = variationsDataSource.fetchVariations(productId, forceRefresh = true) - if (result.isSuccess) { - variationsDataSource.getVariationsFlow(productId).collect { variationList -> - _viewState.value = WooPosVariationsViewState.Content( - items = variationList.filter { it.price != null } - .map { - WooPosItem.Variation( - id = it.remoteVariationId, - name = it.getName(product), - productId = it.remoteProductId, - price = priceFormat(it.price), - imageUrl = it.image?.source - ) - }, - loadingMore = false, - reloadingProductsWithPullToRefresh = false, - ) - } + _viewState.value = if (withPullToRefresh) { + buildProductsReloadingState() } else { - _viewState.value = WooPosVariationsViewState.Error() + WooPosVariationsViewState.Loading(withCart = withCart) + } + + variationsDataSource.fetchFirstPage(productId, forceRefresh = forceRefresh).collect { result -> + when (result) { + is FetchResult.Cached -> { + if (result.data.isNotEmpty()) { + updateViewStateWithVariations(result.data, productId) + } + } + + is FetchResult.Remote -> { + _viewState.value = when { + result.result.isSuccess -> { + val variations = result.result.getOrThrow() + if (variations.isNotEmpty()) { + WooPosVariationsViewState.Content( + items = variations.filter { it.price != null }.map { + WooPosItem.Variation( + id = it.remoteVariationId, + name = it.getName(getProductById(productId)), + productId = it.remoteProductId, + price = priceFormat(it.price), + imageUrl = it.image?.source + ) + }, + paginationState = if (loadMoreJob?.isActive == true) { + PaginationState.Loading + } else { + PaginationState.None + } + ) + } else { + WooPosVariationsViewState.Empty() + } + } + + else -> WooPosVariationsViewState.Error() + } + } + } } } } + private suspend fun updateViewStateWithVariations(variations: List, productId: Long) { + if (variations.isEmpty()) { + _viewState.value = WooPosVariationsViewState.Empty() + } else { + _viewState.value = WooPosVariationsViewState.Content( + items = variations.filter { it.price != null }.map { + WooPosItem.Variation( + id = it.remoteVariationId, + name = it.getName(getProductById(productId)), + productId = it.remoteProductId, + price = priceFormat(it.price), + imageUrl = it.image?.source + ) + } + ) + } + } + private fun buildProductsReloadingState() = when (val state = viewState.value) { is WooPosVariationsViewState.Content -> state.copy(reloadingProductsWithPullToRefresh = true) @@ -92,26 +137,35 @@ class WooPosVariationsViewModel @Inject constructor( is WooPosVariationsViewState.Empty -> state.copy(reloadingProductsWithPullToRefresh = true) } - fun loadMore(productId: Long) { + private fun loadMore(productId: Long, numOfVariations: Int) { val currentState = _viewState.value if (currentState !is WooPosVariationsViewState.Content) { return } - if (!variationsDataSource.canLoadMore()) { + + if (!variationsDataSource.canLoadMore(numOfVariations)) { return } - _viewState.value = currentState.copy(loadingMore = true) + + _viewState.value = currentState.copy(paginationState = PaginationState.Loading) + loadMoreJob?.cancel() loadMoreJob = viewModelScope.launch { val result = variationsDataSource.loadMore(productId) - if (result.isSuccess) { - Result.success(Unit) - if (!variationsDataSource.canLoadMore()) { - _viewState.value = currentState.copy(loadingMore = false) - } + _viewState.value = if (result.isSuccess) { + WooPosVariationsViewState.Content( + items = result.getOrThrow().filter { it.price != null }.map { + WooPosItem.Variation( + id = it.remoteVariationId, + name = it.getName(getProductById(productId)), + productId = it.remoteProductId, + price = priceFormat(it.price), + imageUrl = it.image?.source + ) + } + ) } else { - _events.tryEmit(WooPosVariationEvents.PaginationError) - _viewState.value = currentState.copy(loadingMore = false) + currentState.copy(paginationState = PaginationState.Error) } } } @@ -119,15 +173,15 @@ class WooPosVariationsViewModel @Inject constructor( fun onUIEvent(event: WooPosVariationsUIEvents) { when (event) { is WooPosVariationsUIEvents.EndOfItemsListReached -> { - onEndOfVariationsListReached(event.productId) + onEndOfVariationsListReached(event.productId, event.numOfVariations) } is WooPosVariationsUIEvents.PullToRefreshTriggered -> { - fetchVariations(event.productId, withPullToRefresh = true, withCart = false) + loadVariations(event.productId, forceRefresh = true, withPullToRefresh = true, withCart = false) } is WooPosVariationsUIEvents.VariationsLoadingErrorRetryButtonClicked -> { - fetchVariations(event.productId, withPullToRefresh = false, withCart = false) + loadVariations(event.productId, forceRefresh = true, withPullToRefresh = false, withCart = false) } is WooPosVariationsUIEvents.OnItemClicked -> { @@ -148,11 +202,7 @@ class WooPosVariationsViewModel @Inject constructor( viewModelScope.launch { fromChildToParentEventSender.sendToParent(event) } } - private fun onEndOfVariationsListReached(productId: Long) { - loadMore(productId) - } - - sealed class WooPosVariationEvents { - data object PaginationError : WooPosVariationEvents() + private fun onEndOfVariationsListReached(productId: Long, numOfVariations: Int) { + loadMore(productId, numOfVariations) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModel.kt index af526d3c26f..d61e33cb931 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModel.kt @@ -15,6 +15,7 @@ import com.woocommerce.android.ui.woopos.home.ParentToChildrenEvent import com.woocommerce.android.ui.woopos.home.WooPosChildrenToParentEventSender import com.woocommerce.android.ui.woopos.home.WooPosParentToChildrenEventReceiver import com.woocommerce.android.ui.woopos.home.items.WooPosItemsViewModel +import com.woocommerce.android.ui.woopos.home.items.navigation.WooPosItemsNavigator import com.woocommerce.android.ui.woopos.home.totals.WooPosTotalsViewState.ReceiptSending import com.woocommerce.android.ui.woopos.home.totals.payment.receipt.WooPosTotalsPaymentReceiptIsSendingSupported import com.woocommerce.android.ui.woopos.home.totals.payment.receipt.WooPosTotalsPaymentReceiptIsSendingSupported.Companion.WC_VERSION_SUPPORTS_SENDING_RECEIPTS_BY_EMAIL @@ -49,6 +50,7 @@ class WooPosTotalsViewModel @Inject constructor( private val priceFormat: WooPosFormatPrice, private val analyticsTracker: WooPosAnalyticsTracker, private val networkStatus: WooPosNetworkStatus, + private val wooPosItemsNavigator: WooPosItemsNavigator, private val isReceiptSendingSupported: WooPosTotalsPaymentReceiptIsSendingSupported, private val isReceiptsEnabled: WooPosIsReceiptsEnabled, private val isCashPaymentsEnabled: WooPosIsCashPaymentsEnabled, @@ -174,6 +176,9 @@ class WooPosTotalsViewModel @Inject constructor( cardReaderFacade.paymentStatus.collect { status -> when (status) { is WooPosCardReaderPaymentStatus.Success -> { + wooPosItemsNavigator.sendNavigationEvent( + WooPosItemsNavigator.WooPosItemsScreenNavigationEvent.NavigateBackToItemListScreen + ) childrenToParentEventSender.sendToParent(ChildToParentEvent.OrderSuccessfullyPaid) } is WooPosCardReaderPaymentStatus.Failure, diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 9ea83672b47..2a39bfec8a6 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -4238,19 +4238,19 @@ Cart Clear - Showing simple products only - Only simple physical products are compatible with POS right now. Other product types, such as variable and virtual, will become available in future updates. + Showing simple and variable products only + Only simple physical and variable products are compatible with POS right now. Other product types, such as virtual, will become available in future updates. Learn\u00A0more Close Info - Simple products only banner + Simple and variable products only banner Double tap to learn more - Simple products only dialog + Simple and variable products only dialog Double tap to dismiss the dialog Why can\'t I see my products? - Only simple physical products can be used with POS right now. - Other product types, such as variable and virtual, will be available in future updates. + Only simple physical and variable products can be used with POS right now. + Other product types, such as virtual, will be available in future updates. To take payment for a non-simple product, exit POS and create a new order from the orders tab. + Create an order in store management OK @@ -4288,7 +4288,7 @@ Cart is empty No products No supported products found - POS currently only supports simple products – \ncreate one to get started. + POS currently only supports simple and variable products – \ncreate one to get started. POS currently only supports simple products Error loading products Give it another go? @@ -4310,7 +4310,9 @@ %1$d variations Back - "Failed to load more items. Please try again." + Load more + "Failed to load more items. Please try again." + "Failed to load more items. Double tap to try again." Error loading variations Cash payment diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsViewModelTest.kt index 1a9822dd5f4..d7ed1fc418e 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsViewModelTest.kt @@ -205,7 +205,7 @@ class WooPosItemsViewModelTest { viewModel.viewState.test { // THEN val value = awaitItem() as WooPosItemsViewState.Content - assertThat(value.loadingMore).isFalse() + assertThat(value.paginationState).isEqualTo(PaginationState.None) } } @@ -288,7 +288,7 @@ class WooPosItemsViewModelTest { viewModel.viewState.test { // THEN val value = awaitItem() as WooPosItemsViewState.Content - assertThat(value.loadingMore).isFalse() + assertThat(value.paginationState).isEqualTo(PaginationState.None) } } @@ -347,7 +347,7 @@ class WooPosItemsViewModelTest { } @Test - fun `given error from load more, when list end reached, then state is Error`() = runTest { + fun `given error from load more, when list end reached, then state is pagination error`() = runTest { // GIVEN val products = listOf( ProductTestUtils.generateProduct( @@ -373,8 +373,8 @@ class WooPosItemsViewModelTest { viewModel.onUIEvent(WooPosItemsUIEvent.EndOfItemsListReached) viewModel.viewState.test { // THEN - val value = awaitItem() - assertThat(value).isInstanceOf(WooPosItemsViewState.Error::class.java) + val value = awaitItem() as ContentViewState + assertThat(value.paginationState).isInstanceOf(PaginationState.Error::class.java) } } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModelTest.kt index d0098884a90..04173ce8dfc 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/totals/WooPosTotalsViewModelTest.kt @@ -14,6 +14,7 @@ import com.woocommerce.android.ui.woopos.home.ParentToChildrenEvent.OrderSuccess import com.woocommerce.android.ui.woopos.home.WooPosChildrenToParentEventSender import com.woocommerce.android.ui.woopos.home.WooPosParentToChildrenEventReceiver import com.woocommerce.android.ui.woopos.home.items.WooPosItemsViewModel +import com.woocommerce.android.ui.woopos.home.items.navigation.WooPosItemsNavigator import com.woocommerce.android.ui.woopos.home.totals.WooPosTotalsViewState.Totals.CashPaymentAvailability import com.woocommerce.android.ui.woopos.home.totals.payment.receipt.WooPosTotalsPaymentReceiptIsSendingSupported import com.woocommerce.android.ui.woopos.home.totals.payment.receipt.WooPosTotalsPaymentReceiptRepository @@ -52,6 +53,8 @@ class WooPosTotalsViewModelTest { private val networkStatus: WooPosNetworkStatus = mock() + private val wooPosItemsNavigator: WooPosItemsNavigator = mock() + private val childrenToParentEventSender: WooPosChildrenToParentEventSender = mock() private fun createMockSavedStateHandle(): SavedStateHandle { @@ -589,6 +592,72 @@ class WooPosTotalsViewModelTest { verify(childrenToParentEventSender).sendToParent(ChildToParentEvent.OrderSuccessfullyPaid) } + @Test + fun `given payment status is success, when payment flow started, then wooPosItemsNavigator event is sent to update items screen to items list screen`() = runTest { + // GIVEN + whenever(networkStatus.isConnected()).thenReturn(true) + val itemClickedData = listOf( + WooPosItemsViewModel.ItemClickedData.SimpleProduct( + id = 1L + ) + ) + val parentToChildrenEventFlow = MutableStateFlow(ParentToChildrenEvent.CheckoutClicked(itemClickedData)) + val parentToChildrenEventReceiver: WooPosParentToChildrenEventReceiver = mock { + on { events }.thenReturn(parentToChildrenEventFlow) + } + + val order = Order.getEmptyOrder( + dateCreated = Date(), + dateModified = Date() + ).copy( + id = 123L, + totalTax = BigDecimal("2.00"), + items = listOf( + Order.Item.EMPTY.copy(subtotal = BigDecimal("1.00")), + ), + total = BigDecimal("3.00"), + productsTotal = BigDecimal("5.00"), + ) + + val totalsRepository: WooPosTotalsRepository = mock { + onBlocking { createOrderWithProducts(any()) }.thenReturn(Result.success(order)) + } + + val savedState = createMockSavedStateHandle() + val priceFormat: WooPosFormatPrice = mock { + onBlocking { invoke(BigDecimal("1.00")) }.thenReturn("$1.00") + onBlocking { invoke(BigDecimal("2.00")) }.thenReturn("$2.00") + onBlocking { invoke(BigDecimal("3.00")) }.thenReturn("$3.00") + onBlocking { invoke(BigDecimal("5.00")) }.thenReturn("5.00$") + } + val resourceProvider: ResourceProvider = mock { + on { getString(R.string.woopos_success_screen_total, "$3.00") }.thenReturn( + "A payment of $3.00 was successfully made" + ) + } + + val paymentStatusFlow = MutableStateFlow(WooPosCardReaderPaymentStatus.Unknown) + whenever(cardReaderFacade.paymentStatus).thenReturn(paymentStatusFlow) + + val viewModel = createViewModel( + resourceProvider = resourceProvider, + savedState = savedState, + parentToChildrenEventReceiver = parentToChildrenEventReceiver, + totalsRepository = totalsRepository, + priceFormat = priceFormat, + ) + + // WHEN + viewModel.onUIEvent(WooPosTotalsUIEvent.CollectPaymentClicked) + paymentStatusFlow.value = WooPosCardReaderPaymentStatus.Success + advanceUntilIdle() + + // THEN + verify(wooPosItemsNavigator).sendNavigationEvent( + WooPosItemsNavigator.WooPosItemsScreenNavigationEvent.NavigateBackToItemListScreen + ) + } + @Test fun `given there is no internet, when trying to complete payment, then trigger proper event`() = runTest { // GIVEN @@ -761,6 +830,7 @@ class WooPosTotalsViewModelTest { priceFormat = priceFormat, analyticsTracker = analyticsTracker, networkStatus = networkStatus, + wooPosItemsNavigator = wooPosItemsNavigator, isReceiptSendingSupported = isReceiptSendingSupported, isReceiptsEnabled = isReceiptsEnabled, isCashPaymentsEnabled = isCashPaymentsEnabled, diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/variations/WooPosVariationsViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/variations/WooPosVariationsViewModelTest.kt index a525f6ae82b..e97133a768c 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/variations/WooPosVariationsViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/variations/WooPosVariationsViewModelTest.kt @@ -1,527 +1,287 @@ package com.woocommerce.android.ui.woopos.home.variations -import android.annotation.SuppressLint -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.asLiveData import app.cash.turbine.test import com.woocommerce.android.ui.products.ProductTestUtils import com.woocommerce.android.ui.woopos.common.data.WooPosGetProductById import com.woocommerce.android.ui.woopos.home.ChildToParentEvent import com.woocommerce.android.ui.woopos.home.WooPosChildrenToParentEventSender +import com.woocommerce.android.ui.woopos.home.items.PaginationState import com.woocommerce.android.ui.woopos.home.items.WooPosItemsViewModel import com.woocommerce.android.ui.woopos.home.items.WooPosVariationsViewState +import com.woocommerce.android.ui.woopos.home.items.variations.FetchResult import com.woocommerce.android.ui.woopos.home.items.variations.WooPosVariationsDataSource import com.woocommerce.android.ui.woopos.home.items.variations.WooPosVariationsUIEvents import com.woocommerce.android.ui.woopos.home.items.variations.WooPosVariationsViewModel import com.woocommerce.android.ui.woopos.util.WooPosCoroutineTestRule import com.woocommerce.android.ui.woopos.util.format.WooPosFormatPrice import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.Rule -import org.junit.Test -import org.mockito.ArgumentMatchers.eq import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import kotlin.test.assertFalse -import kotlin.test.assertTrue +import kotlin.test.Test @ExperimentalCoroutinesApi class WooPosVariationsViewModelTest { - @Rule @JvmField - val rule = InstantTaskExecutorRule() - @Rule @JvmField val coroutinesTestRule = WooPosCoroutineTestRule() private val getProductById: WooPosGetProductById = mock() - private val childrenToParentEventSender: WooPosChildrenToParentEventSender = mock() private val variationsDataSource: WooPosVariationsDataSource = mock() + private val fromChildToParentEventSender: WooPosChildrenToParentEventSender = mock() private val priceFormat: WooPosFormatPrice = mock { onBlocking { invoke(any()) }.thenReturn("$10.0") } - private lateinit var wooPosVariationsViewModel: WooPosVariationsViewModel @Test - fun `given view model init, then loading state is displayed`() = runTest { - whenever(variationsDataSource.getVariationsFlow(1L)).thenReturn(emptyFlow()) - - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat + fun `given variations from data source, when view model created, then view state updated correctly`() = runTest { + // GIVEN + val variations = listOf( + ProductTestUtils.generateProductVariation(1, 1, "10.0"), + ProductTestUtils.generateProductVariation(2, 1, "20.0") ) - wooPosVariationsViewModel.init(1L) - - assertThat( - wooPosVariationsViewModel.viewState.value - ).isEqualTo( - WooPosVariationsViewState.Loading(withCart = true) + whenever(variationsDataSource.fetchFirstPage(any(), any())).thenReturn( + flowOf(FetchResult.Remote(Result.success(variations))) ) - } - @Test - fun `given view model init, then API call is made to fetch product`() = runTest { - whenever(variationsDataSource.getVariationsFlow(1L)).thenReturn(emptyFlow()) + // WHEN + val viewModel = createViewModel() + viewModel.init(1L) - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat - ) - wooPosVariationsViewModel.init(1L) - - verify(getProductById).invoke(1L) + viewModel.viewState.test { + // THEN + val state = awaitItem() as WooPosVariationsViewState.Content + assertThat(state.items).hasSize(2) + assertThat(state.items[0].id).isEqualTo(1) + assertThat(state.items[0].price).isEqualTo("$10.0") + } } @Test - fun `given view model init, then API call is made to fetch variation`() = runTest { - whenever(variationsDataSource.getVariationsFlow(1L)).thenReturn(emptyFlow()) - - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat + fun `given empty variations list returned, when view model created, then view state is empty`() = runTest { + // GIVEN + whenever(variationsDataSource.fetchFirstPage(any(), any())).thenReturn( + flowOf(FetchResult.Remote(Result.success(emptyList()))) ) - wooPosVariationsViewModel.init(1L) - verify(variationsDataSource).fetchVariations(eq(1L), any()) + // WHEN + val viewModel = createViewModel() + viewModel.init(1L) + viewModel.viewState.test { + // THEN + assertThat(awaitItem()).isEqualTo(WooPosVariationsViewState.Empty()) + } } @Test - fun `given view model init, then API call is made to fetch variation with forceRefresh set to true`() = runTest { - whenever(variationsDataSource.getVariationsFlow(1L)).thenReturn(emptyFlow()) - - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat + fun `given error fetching variations, when view model created, then view state is error`() = runTest { + // GIVEN + whenever(variationsDataSource.fetchFirstPage(any(), any())).thenReturn( + flowOf(FetchResult.Remote(Result.failure(Exception("Fetch error")))) ) - wooPosVariationsViewModel.init(1L) - verify(variationsDataSource).fetchVariations(1L, forceRefresh = true) + // WHEN + val viewModel = createViewModel() + viewModel.init(1L) + + viewModel.viewState.test { + // THEN + assertThat(awaitItem()).isEqualTo(WooPosVariationsViewState.Error()) + } } @Test - fun `given view model init, when variation fetched successfully, then view state is updated with variation content`() = - runTest { - whenever(variationsDataSource.getVariationsFlow(any())).thenReturn( - flowOf( - listOf( - ProductTestUtils.generateProductVariation(1L, 2L), - ProductTestUtils.generateProductVariation(1L, 3L), - ProductTestUtils.generateProductVariation(1L, 4L), - ) - ) - ) - whenever(getProductById.invoke(any())).thenReturn( - ProductTestUtils.generateProduct(1L, isVariable = true, productType = "variable") - ) + fun `given variations, when pull to refresh triggered, then fetchFirstPage is called`() = runTest { + // GIVEN + val variations = listOf( + ProductTestUtils.generateProductVariation(1, 1, "10.0"), + ProductTestUtils.generateProductVariation(2, 1, "20.0") + ) + whenever(variationsDataSource.fetchFirstPage(any(), eq(true))).thenReturn( + flowOf(FetchResult.Remote(Result.success(variations))) + ) - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat - ) - wooPosVariationsViewModel.init(1L) - advanceUntilIdle() + // WHEN + val viewModel = createViewModel() + viewModel.onUIEvent(WooPosVariationsUIEvents.PullToRefreshTriggered(123L)) - wooPosVariationsViewModel.viewState.test { - // THEN - val value = awaitItem() as WooPosVariationsViewState.Content - assertThat(value).isInstanceOf(WooPosVariationsViewState.Content::class.java) - } - } + // THEN + verify(variationsDataSource).fetchFirstPage(eq(123L), eq(true)) + } @Test - fun `given view model init, when variation fetched successfully, then filter out variations with price null`() = - runTest { - whenever(variationsDataSource.getVariationsFlow(any())).thenReturn( - flowOf( - listOf( - ProductTestUtils.generateProductVariation(1L, 2L, amount = ""), - ProductTestUtils.generateProductVariation(1L, 3L), - ProductTestUtils.generateProductVariation(1L, 4L), - ) - ) - ) - whenever(getProductById.invoke(any())).thenReturn( - ProductTestUtils.generateProduct(1L, isVariable = true, productType = "variable") - ) - - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat - ) - wooPosVariationsViewModel.init(1L) - advanceUntilIdle() - - wooPosVariationsViewModel.viewState.test { - // THEN - val value = awaitItem() as WooPosVariationsViewState.Content - assertThat(value.items.size).isEqualTo(2) - } + fun `given no more variations, when end of list reached, then pagination state is none`() = runTest { + // GIVEN + val variations = listOf( + ProductTestUtils.generateProductVariation(1, 1, "10.0"), + ProductTestUtils.generateProductVariation(2, 1, "20.0") + ) + whenever(variationsDataSource.fetchFirstPage(any(), any())).thenReturn( + flowOf(FetchResult.Remote(Result.success(variations))) + ) + whenever(variationsDataSource.canLoadMore(any())).thenReturn(false) + whenever(variationsDataSource.loadMore(any())).thenReturn(Result.success(emptyList())) + + val viewModel = createViewModel() + viewModel.init(1L) + advanceUntilIdle() + + // WHEN + viewModel.onUIEvent(WooPosVariationsUIEvents.EndOfItemsListReached(123L, 10)) + advanceUntilIdle() + // THEN + viewModel.viewState.test { + val state = awaitItem() as WooPosVariationsViewState.Content + assertThat(state.paginationState).isEqualTo(PaginationState.None) } + } @Test - fun `given view model init, when variation fetched successfully, then view state is updated with proper variation content`() = - runTest { - whenever(variationsDataSource.getVariationsFlow(1L)).thenReturn( - flowOf( - listOf( - ProductTestUtils.generateProductVariation(1L, 2L), - ProductTestUtils.generateProductVariation(1L, 3L), - ProductTestUtils.generateProductVariation(1L, 4L), + fun `given variations, when load more succeeds, then pagination state is updated`() = runTest { + // GIVEN + val variations = listOf( + ProductTestUtils.generateProductVariation(1, 1, "10.0"), + ) + whenever(variationsDataSource.loadMore(any())).thenReturn(Result.success(variations)) + whenever(variationsDataSource.canLoadMore(any())).thenReturn(true) + whenever(variationsDataSource.fetchFirstPage(any(), any())).thenReturn( + flow { + emit( + FetchResult.Remote( + Result.success( + listOf( + ProductTestUtils.generateProductVariation(1, 1, "10.0"), + ProductTestUtils.generateProductVariation(2, 1, "20.0") + ) + ) ) ) - ) - whenever(getProductById.invoke(any())).thenReturn( - ProductTestUtils.generateProduct(1L, isVariable = true, productType = "variable") - ) - - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat - ) - wooPosVariationsViewModel.init(1L) - advanceUntilIdle() - - wooPosVariationsViewModel.viewState.test { - // THEN - val value = awaitItem() as WooPosVariationsViewState.Content - assertThat(value.items.size).isEqualTo(3) - assertThat(value.items[0].id).isEqualTo(2) - assertThat(value.items[1].id).isEqualTo(3) - assertThat(value.items[2].id).isEqualTo(4) - assertFalse(value.loadingMore) - assertFalse(value.reloadingProductsWithPullToRefresh) } - } - - @Test - fun `given variation fetch fails, when retry clicked, then fetch variations called`() = - runTest { - whenever(variationsDataSource.getVariationsFlow(any())).thenReturn( - emptyFlow() - ) - whenever(variationsDataSource.fetchVariations(any(), any())).thenReturn( - Result.failure(Throwable()) - ) - whenever(getProductById.invoke(any())).thenReturn( - ProductTestUtils.generateProduct(1L, isVariable = true, productType = "variable") - ) - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat - ) + ) - wooPosVariationsViewModel.init(1L) - wooPosVariationsViewModel.onUIEvent( - WooPosVariationsUIEvents.VariationsLoadingErrorRetryButtonClicked(1L) - ) + val viewModel = createViewModel() + viewModel.init(1L) + viewModel.onUIEvent(WooPosVariationsUIEvents.EndOfItemsListReached(123L, 10)) - verify(variationsDataSource, times(2)).fetchVariations(1L) + // THEN + viewModel.viewState.test { + val state = awaitItem() as WooPosVariationsViewState.Content + assertThat(state.items).hasSize(1) + assertThat(state.items[0].id).isEqualTo(1) } + } - @SuppressLint("CheckResult") @Test - fun `given view state is content, when pull to refreshed, then view state contains Content state with pull to refresh set to true`() = - runTest { - whenever(variationsDataSource.getVariationsFlow(any())).thenReturn( - flowOf( - listOf( - ProductTestUtils.generateProductVariation(1L, 2L), - ProductTestUtils.generateProductVariation(1L, 3L), - ProductTestUtils.generateProductVariation(1L, 4L), + fun `given load more fails, when end of list reached, then pagination state is error`() = runTest { + // GIVEN + whenever(variationsDataSource.loadMore(any())).thenReturn(Result.failure(Exception())) + whenever(variationsDataSource.canLoadMore(any())).thenReturn(true) + whenever(variationsDataSource.fetchFirstPage(any(), any())).thenReturn( + flow { + emit( + FetchResult.Remote( + Result.success( + listOf( + ProductTestUtils.generateProductVariation(1, 1, "10.0"), + ProductTestUtils.generateProductVariation(2, 1, "20.0") + ) + ) ) ) - ) - whenever(getProductById.invoke(any())).thenReturn( - ProductTestUtils.generateProduct(1L, isVariable = true, productType = "variable") - ) - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat - ) - val states: MutableList = mutableListOf() - wooPosVariationsViewModel.viewState.asLiveData().observeForever { - states.add(it) } + ) - wooPosVariationsViewModel.init(1L) - wooPosVariationsViewModel.onUIEvent(WooPosVariationsUIEvents.PullToRefreshTriggered(1L)) - - assertThat( - states.any { (it as? WooPosVariationsViewState.Content)?.reloadingProductsWithPullToRefresh == true } - ) - } - - @Test - fun `given view state is Loading, when pull to refreshed, then view state contains Loading state with pull to refresh set to true`() = - runTest { - whenever(variationsDataSource.getVariationsFlow(1L)).thenReturn( - emptyFlow() - ) - whenever(getProductById.invoke(any())).thenReturn( - ProductTestUtils.generateProduct(1L, isVariable = true, productType = "variable") - ) - - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat - ) - wooPosVariationsViewModel.init(1L) - wooPosVariationsViewModel.onUIEvent(WooPosVariationsUIEvents.PullToRefreshTriggered(1L)) + val viewModel = createViewModel() + viewModel.init(1L) + advanceUntilIdle() + viewModel.onUIEvent(WooPosVariationsUIEvents.EndOfItemsListReached(123L, 10)) + advanceUntilIdle() - wooPosVariationsViewModel.viewState.test { - // THEN - val value = awaitItem() as WooPosVariationsViewState.Loading - assertTrue(value.reloadingProductsWithPullToRefresh) - } + // THEN + viewModel.viewState.test { + val state = awaitItem() as WooPosVariationsViewState.Content + assertThat(state.paginationState).isEqualTo(PaginationState.Error) } + } @Test - fun `given view state is Error, when fetch variations, then view state is updated with error state`() = runTest { - whenever(getProductById.invoke(any())).thenReturn( - ProductTestUtils.generateProduct(1L, isVariable = true, productType = "variable") + fun `given fetching variations first page and load more call is also happening, when view model created, then view state updated correctly`() = runTest { + // GIVEN + val variations = listOf( + ProductTestUtils.generateProductVariation(1, 1, "10.0"), + ProductTestUtils.generateProductVariation(2, 1, "20.0") ) - whenever(variationsDataSource.fetchVariations(any(), any())).thenReturn( - Result.failure(Throwable()) + whenever(variationsDataSource.fetchFirstPage(any(), any())).thenReturn( + flowOf(FetchResult.Remote(Result.success(variations))) ) - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat - ) - wooPosVariationsViewModel.init(1L) + // WHEN + val viewModel = createViewModel() + val activeJob = Job() + viewModel.loadMoreJob = activeJob + viewModel.init(1L) + viewModel.onUIEvent(WooPosVariationsUIEvents.EndOfItemsListReached(1L, 10)) - wooPosVariationsViewModel.viewState.test { + viewModel.viewState.test { // THEN - val value = awaitItem() as WooPosVariationsViewState.Error - assertThat(value).isInstanceOf(WooPosVariationsViewState.Error::class.java) + val state = awaitItem() as WooPosVariationsViewState.Content + assertThat(state.paginationState).isEqualTo(PaginationState.Loading) } } @Test - fun `given view state is Error, when pull to refreshed, then view state is updated with proper variation content`() = - runTest { - whenever(getProductById.invoke(any())).thenReturn( - ProductTestUtils.generateProduct(1L, isVariable = true, productType = "variable") - ) - whenever(variationsDataSource.fetchVariations(any(), any())).thenReturn( - Result.failure(Throwable()) - ) - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat - ) - val states: MutableList = mutableListOf() - wooPosVariationsViewModel.viewState.asLiveData().observeForever { - states.add(it) - } - - wooPosVariationsViewModel.init(1L) - wooPosVariationsViewModel.onUIEvent(WooPosVariationsUIEvents.PullToRefreshTriggered(1L)) - - assertThat( - states.any { (it as? WooPosVariationsViewState.Error)?.reloadingProductsWithPullToRefresh == true } - ) - } - - @Test - fun `when end of list reached, then load more called`() = - runTest { - whenever(variationsDataSource.getVariationsFlow(any())).thenReturn( - flowOf( - listOf( - ProductTestUtils.generateProductVariation(1L, 2L), - ProductTestUtils.generateProductVariation(1L, 3L), - ProductTestUtils.generateProductVariation(1L, 4L), - ) - ) - ) - whenever(getProductById.invoke(any())).thenReturn( - ProductTestUtils.generateProduct(1L, isVariable = true, productType = "variable") - ) - whenever(variationsDataSource.canLoadMore()).thenReturn(true) - whenever(variationsDataSource.loadMore(any())).thenReturn( - Result.failure(Throwable()) - ) - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat - ) - wooPosVariationsViewModel.init(1L) - wooPosVariationsViewModel.onUIEvent(WooPosVariationsUIEvents.EndOfItemsListReached(1L)) - - verify(variationsDataSource).loadMore(1L) - } - - @Test - fun `given view state that is not Content, when load more is called, then return with doing nothing`() = - runTest { - whenever(variationsDataSource.getVariationsFlow(1L)).thenReturn( - emptyFlow() - ) - whenever(variationsDataSource.loadMore(any())).thenReturn( - Result.failure(Throwable()) - ) - - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat - ) - wooPosVariationsViewModel.init(1L) - wooPosVariationsViewModel.loadMore(1L) - - verify(variationsDataSource, never()).loadMore(1L) - } - - @Test - fun `given no more items to load, when load more is called, then return with doing nothing`() = - runTest { - whenever(variationsDataSource.getVariationsFlow(1L)).thenReturn( - emptyFlow() - ) - whenever(variationsDataSource.loadMore(any())).thenReturn( - Result.failure(Throwable()) - ) - whenever(variationsDataSource.canLoadMore()).thenReturn(false) + fun `given fetching variations first page and load more call is not happening, when view model created, then view state updated correctly`() = runTest { + // GIVEN + val variations = listOf( + ProductTestUtils.generateProductVariation(1, 1, "10.0"), + ProductTestUtils.generateProductVariation(2, 1, "20.0") + ) + whenever(variationsDataSource.fetchFirstPage(any(), any())).thenReturn( + flowOf(FetchResult.Remote(Result.success(variations))) + ) - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat - ) - wooPosVariationsViewModel.init(1L) - wooPosVariationsViewModel.loadMore(1L) + // WHEN + val viewModel = createViewModel() + viewModel.init(1L) - verify(variationsDataSource, never()).loadMore(1L) + viewModel.viewState.test { + // THEN + val state = awaitItem() as WooPosVariationsViewState.Content + assertThat(state.paginationState).isEqualTo(PaginationState.None) } + } @Test - fun `given more items to load, when load more is called, then view state is updated properly`() = - runTest { - whenever(variationsDataSource.getVariationsFlow(1L)).thenReturn( - flowOf( - listOf( - ProductTestUtils.generateProductVariation(1L, 2L), - ProductTestUtils.generateProductVariation(1L, 3L), - ProductTestUtils.generateProductVariation(1L, 4L), - ) - ) - ) - whenever(getProductById.invoke(any())).thenReturn( - ProductTestUtils.generateProduct(1L, isVariable = true, productType = "variable") - ) - whenever(variationsDataSource.loadMore(any())).thenReturn( - Result.success(Unit) - ) - whenever(variationsDataSource.canLoadMore()).thenReturn(true) - - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat - ) - wooPosVariationsViewModel.init(1L) - wooPosVariationsViewModel.loadMore(1L) + fun `given variation clicked, when item clicked, then send event to parent`() = runTest { + // GIVEN + val viewModel = createViewModel() - wooPosVariationsViewModel.viewState.test { - val value = awaitItem() as WooPosVariationsViewState.Content - assertTrue(value.loadingMore) - } - } - - @Test - fun `given load more call fails, when load more is called, then view state is updated properly`() = - runTest { - whenever(variationsDataSource.getVariationsFlow(1L)).thenReturn( - flowOf( - listOf( - ProductTestUtils.generateProductVariation(1L, 2L), - ProductTestUtils.generateProductVariation(1L, 3L), - ProductTestUtils.generateProductVariation(1L, 4L), - ) - ) - ) - whenever(getProductById.invoke(any())).thenReturn( - ProductTestUtils.generateProduct(1L, isVariable = true, productType = "variable") - ) - whenever(variationsDataSource.loadMore(any())).thenReturn( - Result.failure(Throwable()) - ) - whenever(variationsDataSource.canLoadMore()).thenReturn(true) + // WHEN + viewModel.onUIEvent(WooPosVariationsUIEvents.OnItemClicked(123L, 1L)) - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, - getProductById, - variationsDataSource, - priceFormat + // THEN + verify(fromChildToParentEventSender).sendToParent( + ChildToParentEvent.ItemClickedInProductSelector( + WooPosItemsViewModel.ItemClickedData.Variation(123L, 1L) ) - wooPosVariationsViewModel.init(1L) - wooPosVariationsViewModel.loadMore(1L) - - wooPosVariationsViewModel.viewState.test { - val value = awaitItem() as WooPosVariationsViewState.Content - assertFalse(value.loadingMore) - } - } + ) + } - @Test - fun `given OnItemClicked event, when onUIEvent is called, then onVariationClicked is triggered`() = runTest { - // Arrange - val productId = 1L - val variationId = 2L - wooPosVariationsViewModel = WooPosVariationsViewModel( - childrenToParentEventSender, + private fun createViewModel() = + WooPosVariationsViewModel( + fromChildToParentEventSender, getProductById, variationsDataSource, priceFormat ) - - // Act - wooPosVariationsViewModel.onUIEvent(WooPosVariationsUIEvents.OnItemClicked(productId, variationId)) - - // Assert - verify(childrenToParentEventSender).sendToParent( - ChildToParentEvent.ItemClickedInProductSelector( - WooPosItemsViewModel.ItemClickedData.Variation(productId, variationId) - ) - ) - } }