diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt index eca38818752..38946223120 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt @@ -14,6 +14,8 @@ import com.woocommerce.android.ui.woopos.home.ParentToChildrenEvent.SearchEvent. import com.woocommerce.android.ui.woopos.home.ParentToChildrenEvent.SearchEvent.RecentSearchSelected import com.woocommerce.android.ui.woopos.home.WooPosHomeState.DialogState import com.woocommerce.android.ui.woopos.home.WooPosHomeState.ScreenPositionState +import com.woocommerce.android.ui.woopos.localcatalog.WooPosIncrementalSyncReason +import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformLocalCatalogIncrementalSync import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.BackToCartTapped import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker @@ -31,6 +33,7 @@ class WooPosHomeViewModel @Inject constructor( private val parentToChildrenEventSender: WooPosParentToChildrenEventSender, private val analyticsTracker: WooPosAnalyticsTracker, private val soundHelper: WooPosSoundHelper, + private val incrementalSync: WooPosPerformLocalCatalogIncrementalSync, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val _state = savedStateHandle.getStateFlow( @@ -54,6 +57,13 @@ class WooPosHomeViewModel @Inject constructor( viewModelScope.launch { soundHelper.preloadChaChing() } + performLocalCatalogIncrementalSync() + } + + private fun performLocalCatalogIncrementalSync() { + viewModelScope.launch { + incrementalSync.execute(WooPosIncrementalSyncReason.ON_POS_HOME) + } } override fun onCleared() { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosCatalogSyncOverdueBanner.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosCatalogSyncOverdueBanner.kt new file mode 100644 index 00000000000..f212fe123ea --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosCatalogSyncOverdueBanner.kt @@ -0,0 +1,113 @@ +package com.woocommerce.android.ui.woopos.home.items + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleIn +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.woocommerce.android.R +import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview +import com.woocommerce.android.ui.woopos.common.composeui.component.ShadowType +import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosCard +import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosText +import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosCornerRadius +import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosElevation +import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosSpacing +import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTheme +import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTypography + +@Composable +fun WooPosCatalogSyncOverdueBanner( + state: WooPosItemsViewModel.CatalogSyncOverdueBannerState, + modifier: Modifier = Modifier, + onDismiss: () -> Unit +) { + AnimatedVisibility( + visible = state is WooPosItemsViewModel.CatalogSyncOverdueBannerState.Visible, + enter = fadeIn( + animationSpec = tween(durationMillis = 180) + ) + scaleIn( + animationSpec = tween(durationMillis = 180) + ), + modifier = modifier.padding(horizontal = WooPosSpacing.Small.value) + ) { + WooPosCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(WooPosCornerRadius.Medium.value), + backgroundColor = MaterialTheme.colorScheme.surfaceContainerLow, + elevation = WooPosElevation.Medium, + shadowType = ShadowType.Soft, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(WooPosSpacing.Medium.value), + modifier = Modifier + .fillMaxWidth() + .padding(WooPosSpacing.Medium.value), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_woo_pos_info_banner), + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .align(Alignment.CenterVertically) + ) + Column( + verticalArrangement = Arrangement.spacedBy(WooPosSpacing.Small.value), + horizontalAlignment = Alignment.Start, + modifier = Modifier.weight(1f) + ) { + WooPosText( + text = stringResource(R.string.woopos_refresh_catalog_banner_title), + style = WooPosTypography.BodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + WooPosText( + text = stringResource(R.string.woopos_refresh_catalog_banner_message), + style = WooPosTypography.BodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + IconButton( + onClick = onDismiss, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.woopos_refresh_catalog_banner_dismiss), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp) + ) + } + } + } + } +} + +@Composable +@WooPosPreview +fun WooPosRefreshCatalogBannerPreview() { + WooPosTheme { + WooPosCatalogSyncOverdueBanner( + state = WooPosItemsViewModel.CatalogSyncOverdueBannerState.Visible, + onDismiss = {} + ) + } +} 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 b3cb489ce07..0ae398220ca 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 @@ -5,8 +5,10 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyListState @@ -45,6 +47,7 @@ fun WooPosItemsScreen(modifier: Modifier = Modifier) { WooPosItemsScreen( modifier = modifier, itemsStateFlow = productsViewModel.viewState, + catalogSyncOverdueBannerStateFlow = productsViewModel.catalogSyncOverdueBannerState, productsViewState = productsViewState, couponsListState = couponsListState, onUIEvent = { productsViewModel.onUIEvent(it) }, @@ -56,15 +59,18 @@ fun WooPosItemsScreen(modifier: Modifier = Modifier) { private fun WooPosItemsScreen( modifier: Modifier = Modifier, itemsStateFlow: StateFlow, + catalogSyncOverdueBannerStateFlow: StateFlow, productsViewState: LazyListState, couponsListState: LazyListState, onUIEvent: (WooPosItemsUIEvent) -> Unit, ) { val state = itemsStateFlow.collectAsState() + val catalogSyncOverdueBannerState = catalogSyncOverdueBannerStateFlow.collectAsState() MainItemsList( modifier = modifier, state = state, + bannerState = catalogSyncOverdueBannerState, productsViewState = productsViewState, couponsListState = couponsListState, onSearchEvent = { @@ -86,6 +92,7 @@ private fun WooPosItemsScreen( }, onTabClicked = { onUIEvent(WooPosItemsUIEvent.OnTabClicked(it)) }, onBackClicked = { onUIEvent(WooPosItemsUIEvent.BackFromVariationsClicked) }, + onSyncWarningBannerDismissed = { onUIEvent(WooPosItemsUIEvent.SyncOverdueBannerDismissed) }, ) } @@ -94,20 +101,17 @@ private fun WooPosItemsScreen( private fun MainItemsList( modifier: Modifier, state: State, + bannerState: State, productsViewState: LazyListState, couponsListState: LazyListState, onSearchEvent: (WooPosSearchUIEvent) -> Unit, onTabClicked: (WooPosItemsToolbarViewState.Tab) -> Unit, onAddCouponEvent: () -> Unit, onBackClicked: () -> Unit, + onSyncWarningBannerDismissed: () -> Unit, ) { - Box( - modifier = modifier - .fillMaxSize() - ) { - Column( - modifier.fillMaxHeight() - ) { + Box(modifier = modifier.fillMaxSize()) { + Column(modifier.fillMaxHeight()) { WooPosItemsToolbar( modifier = Modifier .statusBarsPadding() @@ -121,6 +125,20 @@ private fun MainItemsList( onAddCouponEvent = onAddCouponEvent, ) + Spacer( + modifier = + Modifier + .height(WooPosSpacing.Small.value) + .padding(horizontal = WooPosSpacing.Medium.value.toAdaptivePadding()) + ) + + WooPosCatalogSyncOverdueBanner( + state = bannerState.value, + onDismiss = onSyncWarningBannerDismissed + ) + + Spacer(modifier = Modifier.height(WooPosSpacing.Small.value)) + val currentState = state.value Crossfade( @@ -216,10 +234,12 @@ fun WooPosItemsScreenSearchVisiblePreview(modifier: Modifier = Modifier) { tabs = tabs() ) ) + val bannerState = MutableStateFlow(WooPosItemsViewModel.CatalogSyncOverdueBannerState.Visible) WooPosTheme { WooPosItemsScreen( modifier = modifier, itemsStateFlow = productState, + catalogSyncOverdueBannerStateFlow = bannerState, productsViewState = rememberLazyListState(), couponsListState = rememberLazyListState(), onUIEvent = {}, @@ -242,10 +262,12 @@ fun WooPosItemsScreenSearchHiddenPreview(modifier: Modifier = Modifier) { tabs = tabs() ) ) + val bannerState = MutableStateFlow(WooPosItemsViewModel.CatalogSyncOverdueBannerState.Visible) WooPosTheme { WooPosItemsScreen( modifier = modifier, itemsStateFlow = productState, + catalogSyncOverdueBannerStateFlow = bannerState, productsViewState = rememberLazyListState(), couponsListState = rememberLazyListState(), onUIEvent = {}, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsUIEvent.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsUIEvent.kt index e8c23354756..39a7562caea 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsUIEvent.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosItemsUIEvent.kt @@ -12,4 +12,5 @@ sealed class WooPosItemsUIEvent { data object CloseSearchClicked : WooPosItemsUIEvent() data object SearchIconClicked : WooPosItemsUIEvent() data object AddCouponIconClicked : WooPosItemsUIEvent() + data object SyncOverdueBannerDismissed : WooPosItemsUIEvent() } 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 33b11d875f2..59c03eb84a1 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 @@ -12,6 +12,8 @@ import com.woocommerce.android.ui.woopos.home.items.WooPosItemsToolbarViewState. import com.woocommerce.android.ui.woopos.home.items.WooPosItemsToolbarViewState.Tab import com.woocommerce.android.ui.woopos.home.items.coupons.creation.WooPosCouponCreationFacade import com.woocommerce.android.ui.woopos.home.items.variations.WooPosVariationsNavigationData +import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncRequirement +import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncStatusChecker import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.SearchButtonTapped import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEventConstant @@ -35,6 +37,7 @@ class WooPosItemsViewModel @Inject constructor( private val parentToChildrenEventReceiver: WooPosParentToChildrenEventReceiver, private val preferencesRepository: WooPosPreferencesRepository, private val analyticsTracker: WooPosAnalyticsTracker, + private val syncStatusChecker: WooPosFullSyncStatusChecker, ) : ViewModel() { private var preservedStateBeforeOpeningVariations: WooPosItemsToolbarViewState? = null private val _viewState = MutableStateFlow(initialState()) @@ -45,6 +48,10 @@ class WooPosItemsViewModel @Inject constructor( initialValue = _viewState.value, ) + private val _catalogSyncOverdueBannerState = + MutableStateFlow(CatalogSyncOverdueBannerState.Hidden) + val catalogSyncOverdueBannerState: StateFlow = _catalogSyncOverdueBannerState + init { listenUpEvents() searchHelper.initialize( @@ -55,6 +62,18 @@ class WooPosItemsViewModel @Inject constructor( viewModelScope.launch { preferencesRepository.setWasOpenedOnce(true) } + + checkSyncStatusAndUpdateBanner() + } + + private fun checkSyncStatusAndUpdateBanner() { + viewModelScope.launch { + val requirement = syncStatusChecker.checkSyncRequirement() + _catalogSyncOverdueBannerState.value = when (requirement) { + is WooPosFullSyncRequirement.Overdue -> CatalogSyncOverdueBannerState.Visible + else -> CatalogSyncOverdueBannerState.Hidden + } + } } fun onUIEvent(event: WooPosItemsUIEvent) { @@ -77,6 +96,9 @@ class WooPosItemsViewModel @Inject constructor( } is WooPosItemsUIEvent.AddCouponIconClicked -> createAndAddCoupon() + WooPosItemsUIEvent.SyncOverdueBannerDismissed -> { + _catalogSyncOverdueBannerState.value = CatalogSyncOverdueBannerState.Hidden + } } } @@ -241,4 +263,9 @@ class WooPosItemsViewModel @Inject constructor( @Parcelize data class Coupon(override val id: Long, val couponCode: String) : ItemClickedData(id), Parcelable } + + sealed class CatalogSyncOverdueBannerState { + data object Hidden : CatalogSyncOverdueBannerState() + data object Visible : CatalogSyncOverdueBannerState() + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/coupons/WooPosCouponsScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/coupons/WooPosCouponsScreen.kt index e58f4a08c85..93081d44378 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/coupons/WooPosCouponsScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/coupons/WooPosCouponsScreen.kt @@ -2,7 +2,6 @@ package com.woocommerce.android.ui.woopos.home.items.coupons import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi @@ -22,7 +21,6 @@ import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosErrorS import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosErrorScreenButtonState import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosPaginationErrorIndicator import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosIcons -import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosSpacing import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTheme import com.woocommerce.android.ui.woopos.home.items.WooPosCouponsViewState import com.woocommerce.android.ui.woopos.home.items.WooPosItemList @@ -74,7 +72,6 @@ private fun WooPosCouponsScreen( when (val itemsState = state.value) { is WooPosCouponsViewState.Content -> { WooPosItemList( - modifier = Modifier.padding(top = WooPosSpacing.Large.value), state = itemsState, listState = listState, onItemClicked = { item -> onUIEvent(WooPosCouponsUIEvent.CouponClicked(item.id, item.name)) }, @@ -88,9 +85,7 @@ private fun WooPosCouponsScreen( } } - is WooPosCouponsViewState.Loading -> WooPosItemsLoadingIndicator( - modifier = Modifier.padding(top = WooPosSpacing.Large.value) - ) + is WooPosCouponsViewState.Loading -> WooPosItemsLoadingIndicator() is WooPosCouponsViewState.Empty -> WooPosEmptyScreen( modifier = Modifier.fillMaxSize(), diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/products/WooPosProductsScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/products/WooPosProductsScreen.kt index beae50958b3..5607f7705eb 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/products/WooPosProductsScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/products/WooPosProductsScreen.kt @@ -139,7 +139,6 @@ private fun Content( onEndOfItemListReached: () -> Unit ) { WooPosItemList( - modifier = Modifier.padding(top = WooPosSpacing.Large.value), state = itemsState, listState = listState, onItemClicked = onItemClicked, 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 17cf131b487..a0a339ec1c1 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 @@ -103,7 +103,6 @@ private fun WooPosVariationsScreens( when (val itemsState = itemState.value) { is WooPosVariationsViewState.Content -> { WooPosItemList( - modifier = Modifier.padding(top = WooPosSpacing.Large.value), state = itemsState, listState = listState, onItemClicked = { @@ -122,16 +121,12 @@ private fun WooPosVariationsScreens( } is WooPosVariationsViewState.Loading -> { - WooPosItemsLoadingIndicator( - modifier = Modifier.padding(top = WooPosSpacing.Large.value), - ) + WooPosItemsLoadingIndicator() } is WooPosVariationsViewState.Error -> { VariationsError( - modifier = Modifier - .width(640.dp) - .padding(top = WooPosSpacing.Large.value) + modifier = Modifier.width(640.dp) ) { onRetryClicked() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncStatusChecker.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncStatusChecker.kt new file mode 100644 index 00000000000..41e3b649455 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncStatusChecker.kt @@ -0,0 +1,89 @@ +package com.woocommerce.android.ui.woopos.localcatalog + +import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper +import com.woocommerce.android.ui.woopos.featureflags.WooPosLocalCatalogM1Enabled +import com.woocommerce.android.ui.woopos.util.WooPosNetworkStatus +import com.woocommerce.android.ui.woopos.util.datastore.WooPosSyncTimestampManager +import org.wordpress.android.fluxc.model.LocalOrRemoteId +import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogStore +import javax.inject.Inject +import kotlin.time.Duration.Companion.days + +class WooPosFullSyncStatusChecker @Inject constructor( + private val syncTimestampManager: WooPosSyncTimestampManager, + private val selectedSite: SelectedSite, + private val networkStatus: WooPosNetworkStatus, + private val wooPosLocalCatalogM1Enabled: WooPosLocalCatalogM1Enabled, + private val localCatalogStore: WooPosLocalCatalogStore, + private val wooPosLogWrapper: WooPosLogWrapper +) { + @Suppress("ReturnCount") + suspend fun checkSyncRequirement(): WooPosFullSyncRequirement { + if (!wooPosLocalCatalogM1Enabled()) { + wooPosLogWrapper.d("Full sync check skipped: Local catalog feature not enabled") + return WooPosFullSyncRequirement.NotRequired + } + + val site = selectedSite.getOrNull() + if (site == null) { + wooPosLogWrapper.e("Full sync check failed: No site selected") + return WooPosFullSyncRequirement.Error("No site selected") + } + + val lastFullSyncTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() + val productCount = localCatalogStore.getProductCount(LocalOrRemoteId.LocalId(site.id)) + .getOrElse { + wooPosLogWrapper.e("Failed to get product count: ${it.message}") + 0 + } + val catalogIsEmpty = productCount == 0 + + if (lastFullSyncTimestamp == null) { + if (!networkStatus.isConnected()) { + wooPosLogWrapper.e("Cannot perform initial sync: No network connection") + return WooPosFullSyncRequirement.Error("No network connection") + } + wooPosLogWrapper.d("Full sync required: Never synced before") + return WooPosFullSyncRequirement.BlockingRequired + } + + return when { + isFullSyncOverdue(lastFullSyncTimestamp) -> { + if (!networkStatus.isConnected()) { + wooPosLogWrapper.d( + "Full sync overdue but offline - allowing POS to load with cached data " + + "(${if (catalogIsEmpty) "empty catalog" else "$productCount products"})" + ) + } + wooPosLogWrapper.d("Full sync overdue (last sync: $lastFullSyncTimestamp)") + WooPosFullSyncRequirement.Overdue + } + else -> { + wooPosLogWrapper.d( + "Full sync not required: Recent sync at $lastFullSyncTimestamp " + + "(${if (catalogIsEmpty) "empty catalog" else "$productCount products"})" + ) + WooPosFullSyncRequirement.NotRequired + } + } + } + + private fun isFullSyncOverdue(lastSyncTimestamp: Long): Boolean { + val currentTime = System.currentTimeMillis() + val timeSinceLastSync = currentTime - lastSyncTimestamp + val overdueThreshold = FULL_SYNC_OVERDUE_THRESHOLD.inWholeMilliseconds + return timeSinceLastSync >= overdueThreshold + } + + companion object { + private val FULL_SYNC_OVERDUE_THRESHOLD = 7.days + } +} + +sealed class WooPosFullSyncRequirement { + data object NotRequired : WooPosFullSyncRequirement() + data object Overdue : WooPosFullSyncRequirement() + data object BlockingRequired : WooPosFullSyncRequirement() + data class Error(val message: String) : WooPosFullSyncRequirement() +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosIncrementalSyncReason.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosIncrementalSyncReason.kt index 6d0e6583f73..7eebfe157bd 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosIncrementalSyncReason.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosIncrementalSyncReason.kt @@ -8,5 +8,5 @@ package com.woocommerce.android.ui.woopos.localcatalog */ enum class WooPosIncrementalSyncReason(val description: String) { AFTER_SUCCESSFUL_PAYMENT("after successful payment"), - ON_SPLASH_SCREEN("on splash screen"), + ON_POS_HOME("on POS home"), } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncScheduler.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncScheduler.kt index 5964824d8ba..62f55f446c1 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncScheduler.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncScheduler.kt @@ -14,7 +14,9 @@ import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper import com.woocommerce.android.ui.woopos.util.datastore.WooPosPreferencesRepository import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import java.util.Calendar import java.util.concurrent.TimeUnit @@ -86,16 +88,24 @@ class WooPosLocalCatalogSyncScheduler @Inject constructor( } } - fun isPeriodicWorkRunning(): Boolean { - val periodicWork = workManager.getWorkInfosForUniqueWork(WooPosLocalCatalogSyncWorker.WORK_NAME).get() + fun observePeriodicWorkStatus(): Flow { + return workManager.getWorkInfosForUniqueWorkFlow(WooPosLocalCatalogSyncWorker.WORK_NAME) + .map { workInfos -> workInfos.any { it.state == WorkInfo.State.RUNNING } } + } - return periodicWork.any { it.state == WorkInfo.State.RUNNING } + fun observeOneTimeWorkStatus(): Flow { + return workManager.getWorkInfosForUniqueWorkFlow(ONE_TIME_WORK_NAME) + .map { workInfos -> workInfos.any { it.state == WorkInfo.State.RUNNING } } } - fun isOneTimeWorkRunning(): Boolean { - val oneTimeWork = workManager.getWorkInfosForUniqueWork(ONE_TIME_WORK_NAME).get() + fun observeOneTimeWorkInfo(): Flow { + return workManager.getWorkInfosForUniqueWorkFlow(ONE_TIME_WORK_NAME) + .map { workInfos -> workInfos.firstOrNull() } + } - return oneTimeWork.any { it.state == WorkInfo.State.RUNNING } + fun observePeriodicWorkInfo(): Flow { + return workManager.getWorkInfosForUniqueWorkFlow(WooPosLocalCatalogSyncWorker.WORK_NAME) + .map { workInfos -> workInfos.firstOrNull() } } fun updateWorkConstraints() { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncWorker.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncWorker.kt index 6cd0a7932c8..ba9cd156c2f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncWorker.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncWorker.kt @@ -9,6 +9,7 @@ import com.woocommerce.android.ui.login.AccountRepository import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper import com.woocommerce.android.ui.woopos.featureflags.WooPosLocalCatalogM1Enabled import com.woocommerce.android.ui.woopos.util.datastore.WooPosPreferencesRepository +import com.woocommerce.android.ui.woopos.util.datastore.WooPosSyncTimestampManager import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -22,6 +23,7 @@ constructor( private val accountRepository: AccountRepository, private val selectedSite: SelectedSite, private val syncRepository: WooPosLocalCatalogSyncRepository, + private val timestampManager: WooPosSyncTimestampManager, private val logger: WooPosLogWrapper, private val featureFlagM1Enabled: WooPosLocalCatalogM1Enabled, private val preferencesRepository: WooPosPreferencesRepository, @@ -63,6 +65,7 @@ constructor( "Local catalog FULL sync completed successfully. Products: ${fullSyncResult.productsSynced}, " + "Variations: ${fullSyncResult.variationsSynced}, Duration: ${fullSyncResult.syncDurationMs}ms" ) + timestampManager.storeFullSyncLastCompletedTimestamp(System.currentTimeMillis()) logger.d("Starting Local catalog INCREMENTAL sync.") val incrementalSyncResult = syncRepository.syncLocalCatalogIncremental(site) if (incrementalSyncResult is PosLocalCatalogSyncResult.Failure) { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformInstantCatalogFullSync.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformInstantCatalogFullSync.kt new file mode 100644 index 00000000000..e83acddf225 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformInstantCatalogFullSync.kt @@ -0,0 +1,129 @@ +package com.woocommerce.android.ui.woopos.localcatalog + +import androidx.work.WorkInfo +import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper +import com.woocommerce.android.ui.woopos.util.datastore.WooPosSyncTimestampManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class WooPosPerformInstantCatalogFullSync @Inject constructor( + private val syncRepository: WooPosLocalCatalogSyncRepository, + private val syncTimestampManager: WooPosSyncTimestampManager, + private val syncScheduler: WooPosLocalCatalogSyncScheduler, + private val selectedSite: SelectedSite, + private val wooPosLogWrapper: WooPosLogWrapper +) { + operator fun invoke(): Flow = flow { + val isOneTimeRunning = syncScheduler.observeOneTimeWorkStatus().first() + val isPeriodicRunning = syncScheduler.observePeriodicWorkStatus().first() + + when { + isOneTimeRunning -> { + wooPosLogWrapper.d("One-time worker is running, monitoring its progress") + monitorWorkerProgress(syncScheduler.observeOneTimeWorkInfo(), "One-time") + } + isPeriodicRunning -> { + wooPosLogWrapper.d("Periodic worker is running, monitoring its progress") + monitorWorkerProgress(syncScheduler.observePeriodicWorkInfo(), "Periodic") + } + else -> { + performBlockingSync() + } + } + } + + private suspend fun FlowCollector.monitorWorkerProgress( + workInfoFlow: Flow, + workerType: String + ) { + emit(WooPosFullSyncState.InProgress) + + val finalWorkInfo = workInfoFlow + .filter { workInfo -> + workInfo?.state?.isFinished == true || workInfo == null + } + .first() + + handleWorkerCompletion(finalWorkInfo, workerType) + } + + private suspend fun FlowCollector.handleWorkerCompletion( + workInfo: WorkInfo?, + workerType: String + ) { + when (workInfo?.state) { + WorkInfo.State.SUCCEEDED -> { + verifyAndEmitSyncCompletion(workerType, "worker completed successfully") + } + WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { + wooPosLogWrapper.e("$workerType worker failed or cancelled: ${workInfo.state}") + emit(WooPosFullSyncState.Failed("Background sync worker ${workInfo.state}")) + } + null -> { + verifyAndEmitSyncCompletion( + workerType, + "worker info is null but sync timestamp found - assuming success" + ) + } + else -> { + wooPosLogWrapper.e("$workerType worker finished with unexpected state: ${workInfo.state}") + emit(WooPosFullSyncState.Failed("Unexpected worker state: ${workInfo.state}")) + } + } + } + + private suspend fun FlowCollector.verifyAndEmitSyncCompletion( + workerType: String, + successMessage: String + ) { + val completedTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() + if (completedTimestamp != null) { + wooPosLogWrapper.d("$workerType: $successMessage") + emit(WooPosFullSyncState.Success) + } else { + wooPosLogWrapper.e("$workerType: Worker completed but no timestamp found") + emit(WooPosFullSyncState.Failed("Worker completed but sync not verified")) + } + } + + private suspend fun FlowCollector.performBlockingSync() { + val site = selectedSite.getOrNull() + if (site == null) { + wooPosLogWrapper.e("Cannot perform blocking sync: No site selected") + emit(WooPosFullSyncState.Failed("No site selected")) + return + } + + wooPosLogWrapper.d("Starting blocking full sync") + emit(WooPosFullSyncState.InProgress) + + val syncResult = syncRepository.syncLocalCatalogFull(site) + when (syncResult) { + is PosLocalCatalogSyncResult.Success -> { + syncTimestampManager.storeFullSyncLastCompletedTimestamp(System.currentTimeMillis()) + wooPosLogWrapper.d( + "Blocking full sync completed successfully: " + + "${syncResult.productsSynced} products, " + + "${syncResult.variationsSynced} variations synced " + + "in ${syncResult.syncDurationMs}ms" + ) + emit(WooPosFullSyncState.Success) + } + is PosLocalCatalogSyncResult.Failure -> { + wooPosLogWrapper.e("Blocking full sync failed: ${syncResult.error}") + emit(WooPosFullSyncState.Failed(syncResult.error)) + } + } + } +} + +sealed class WooPosFullSyncState { + data object InProgress : WooPosFullSyncState() + data object Success : WooPosFullSyncState() + data class Failed(val error: String) : WooPosFullSyncState() +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformLocalCatalogInitialFullSync.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformLocalCatalogInitialFullSync.kt new file mode 100644 index 00000000000..ab7bb7fbfde --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformLocalCatalogInitialFullSync.kt @@ -0,0 +1,46 @@ +package com.woocommerce.android.ui.woopos.localcatalog + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class WooPosPerformLocalCatalogInitialFullSync @Inject constructor( + private val syncStatusChecker: WooPosFullSyncStatusChecker, + private val performFullSync: WooPosPerformInstantCatalogFullSync, +) { + operator fun invoke(): Flow = flow { + val requirement = syncStatusChecker.checkSyncRequirement() + + when (requirement) { + is WooPosFullSyncRequirement.NotRequired, + is WooPosFullSyncRequirement.Overdue -> { + emit(WooPosLocalCatalogInitialFullSyncState.NotRequired) + } + is WooPosFullSyncRequirement.BlockingRequired -> { + performFullSync().collect { syncStatus -> + when (syncStatus) { + is WooPosFullSyncState.InProgress -> { + emit(WooPosLocalCatalogInitialFullSyncState.Syncing) + } + is WooPosFullSyncState.Success -> { + emit(WooPosLocalCatalogInitialFullSyncState.Completed) + } + is WooPosFullSyncState.Failed -> { + emit(WooPosLocalCatalogInitialFullSyncState.Failed(syncStatus.error)) + } + } + } + } + is WooPosFullSyncRequirement.Error -> { + emit(WooPosLocalCatalogInitialFullSyncState.Failed(requirement.message)) + } + } + } +} + +sealed class WooPosLocalCatalogInitialFullSyncState { + data object NotRequired : WooPosLocalCatalogInitialFullSyncState() + data object Syncing : WooPosLocalCatalogInitialFullSyncState() + data object Completed : WooPosLocalCatalogInitialFullSyncState() + data class Failed(val error: String) : WooPosLocalCatalogInitialFullSyncState() +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashScreen.kt index 0b21c7262e5..62a6271285f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashScreen.kt @@ -1,17 +1,28 @@ package com.woocommerce.android.ui.woopos.splash import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.woocommerce.android.R import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosCircularLoadingIndicator +import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosErrorScreen +import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosErrorScreenButtonState import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTheme import com.woocommerce.android.ui.woopos.root.navigation.WooPosNavigationEvent @@ -24,15 +35,23 @@ fun WooPosSplashScreen(onNavigationEvent: (WooPosNavigationEvent) -> Unit) { onNavigationEvent(WooPosNavigationEvent.BackFromSplashClicked) } - Loading() - - when (state.value) { - is WooPosSplashState.Loading -> {} + when (val currentState = state.value) { + is WooPosSplashState.Loading -> { + Loading() + } + is WooPosSplashState.Syncing -> { + SyncingCatalog() + } + is WooPosSplashState.SyncFailed -> { + SyncFailed( + onRetryClicked = { viewModel.onRetrySync() } + ) + } is WooPosSplashState.Loaded -> { onNavigationEvent(WooPosNavigationEvent.OpenHomeFromSplash) } is WooPosSplashState.NotEligible -> { - val reason = (state.value as WooPosSplashState.NotEligible).reason + val reason = currentState.reason onNavigationEvent(WooPosNavigationEvent.OpenEligibilityScreenFromSplash(reason)) } } @@ -48,6 +67,41 @@ private fun Loading() { } } +@Suppress("WooPosDesignSystemSpacingUsageRule", "WooPosDesignSystemTextUsageRule") +@Composable +private fun SyncingCatalog() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + WooPosCircularLoadingIndicator(modifier = Modifier.size(160.dp)) + Spacer(modifier = Modifier.height(32.dp)) + Text( + text = stringResource(R.string.woopos_home_syncing_catalog_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun SyncFailed(onRetryClicked: () -> Unit) { + WooPosErrorScreen( + message = stringResource(R.string.woopos_home_sync_failed_title), + reason = stringResource(R.string.woopos_home_sync_failed_message), + primaryButton = WooPosErrorScreenButtonState( + text = stringResource(R.string.woopos_home_sync_failed_retry_button), + click = onRetryClicked + ) + ) +} + @Composable @WooPosPreview fun WooPosSplashScreenLoadingPreview() { @@ -55,3 +109,11 @@ fun WooPosSplashScreenLoadingPreview() { Loading() } } + +@Composable +@WooPosPreview +fun WooPosSplashScreenSyncingPreview() { + WooPosTheme { + SyncingCatalog() + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashState.kt index 84bd3f34871..914da9b8e6e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashState.kt @@ -4,6 +4,8 @@ import com.woocommerce.android.ui.woopos.tab.WooPosLaunchability sealed class WooPosSplashState { data object Loading : WooPosSplashState() + data object Syncing : WooPosSplashState() + data class SyncFailed(val error: String) : WooPosSplashState() data object Loaded : WooPosSplashState() data class NotEligible(val reason: WooPosLaunchability.NonLaunchabilityReason) : WooPosSplashState() } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModel.kt index 8d344bff945..7963bbd89d6 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModel.kt @@ -4,14 +4,15 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.woocommerce.android.ui.woopos.common.data.WooPosPopularProductsProvider import com.woocommerce.android.ui.woopos.home.items.products.WooPosProductsDataSource -import com.woocommerce.android.ui.woopos.localcatalog.WooPosIncrementalSyncReason -import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformLocalCatalogIncrementalSync +import com.woocommerce.android.ui.woopos.localcatalog.WooPosLocalCatalogInitialFullSyncState +import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformLocalCatalogInitialFullSync import com.woocommerce.android.ui.woopos.orders.WooPosOrdersInMemoryCache import com.woocommerce.android.ui.woopos.tab.WooPosCanBeLaunchedInTab import com.woocommerce.android.ui.woopos.tab.WooPosLaunchability import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.Loaded import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.joinAll @@ -26,7 +27,7 @@ class WooPosSplashViewModel @Inject constructor( private val analyticsTracker: WooPosAnalyticsTracker, private val posCanBeLaunchedInTab: WooPosCanBeLaunchedInTab, private val ordersCache: WooPosOrdersInMemoryCache, - performIncrementalSyncUseCase: WooPosPerformLocalCatalogIncrementalSync + private val performInitialFullSync: WooPosPerformLocalCatalogInitialFullSync, ) : ViewModel() { private val _state = MutableStateFlow(WooPosSplashState.Loading) val state: StateFlow = _state @@ -34,8 +35,6 @@ class WooPosSplashViewModel @Inject constructor( init { val splashScreenStartTime = System.currentTimeMillis() - performIncrementalSyncUseCase.execute(WooPosIncrementalSyncReason.ON_SPLASH_SCREEN) - viewModelScope.launch { val launchability = posCanBeLaunchedInTab() @@ -49,15 +48,44 @@ class WooPosSplashViewModel @Inject constructor( launch { popularProductsProvider.fetchAndCachePopularProducts() }, launch { ordersCache.clear() } ) - _state.value = WooPosSplashState.Loaded - trackPosLoaded(splashScreenStartTime) + + performInitialFullSync().collect(syncStateCollector(splashScreenStartTime)) + } + } + + fun onRetrySync() { + viewModelScope.launch { + val retryStartTime = System.currentTimeMillis() + _state.value = WooPosSplashState.Syncing + performInitialFullSync().collect(syncStateCollector(retryStartTime)) + } + } + + private fun syncStateCollector( + startTime: Long + ) = FlowCollector { syncState -> + when (syncState) { + is WooPosLocalCatalogInitialFullSyncState.NotRequired -> { + _state.value = WooPosSplashState.Loaded + trackPosLoaded(startTime) + } + is WooPosLocalCatalogInitialFullSyncState.Syncing -> { + _state.value = WooPosSplashState.Syncing + } + is WooPosLocalCatalogInitialFullSyncState.Completed -> { + _state.value = WooPosSplashState.Loaded + trackPosLoaded(startTime) + } + is WooPosLocalCatalogInitialFullSyncState.Failed -> { + _state.value = WooPosSplashState.SyncFailed(syncState.error) + } } } - private suspend fun trackPosLoaded(splashScreenStartTime: Long) { + private suspend fun trackPosLoaded(startTime: Long) { val event = Loaded.apply { val waitingTimeSeconds = TimeUnit.MILLISECONDS.toSeconds( - System.currentTimeMillis() - splashScreenStartTime + System.currentTimeMillis() - startTime ).toFloat() addProperties(mapOf("waiting_time" to waitingTimeSeconds.toString())) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosSyncTimestampManager.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosSyncTimestampManager.kt index b5151fb31c3..594cd5c73a2 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosSyncTimestampManager.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosSyncTimestampManager.kt @@ -35,6 +35,12 @@ class WooPosSyncTimestampManager @Inject constructor( timestampRepository.clearAllSyncTimestamps() } + suspend fun storeFullSyncLastCompletedTimestamp(timestamp: Long) { + timestampRepository.storeFullSyncLastCompletedTimestamp(timestamp) + } + + suspend fun getFullSyncLastCompletedTimestamp(): Long? = timestampRepository.getFullSyncLastCompletedTimestamp() + fun formatTimestampForApi(timestamp: Long): String { return defaultApiDateFormatter.format(Instant.ofEpochMilli(timestamp)) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosSyncTimestampRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosSyncTimestampRepository.kt index 5f3ea429232..686306065d3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosSyncTimestampRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/datastore/WooPosSyncTimestampRepository.kt @@ -72,15 +72,35 @@ class WooPosSyncTimestampRepository @Inject constructor( suspend fun clearAllSyncTimestamps() { val productsKey = buildSiteSpecificKey(PRODUCTS_TIMESTAMP_KEY) val variationsKey = buildSiteSpecificKey(VARIATIONS_TIMESTAMP_KEY) + val fullSyncKey = buildSiteSpecificKey(FULL_SYNC_TIMESTAMP_KEY) - if (productsKey != null && variationsKey != null) { + if (productsKey != null && variationsKey != null && fullSyncKey != null) { dataStore.edit { preferences -> preferences.remove(productsKey) preferences.remove(variationsKey) + preferences.remove(fullSyncKey) } } } + suspend fun storeFullSyncLastCompletedTimestamp(timestamp: Long) { + val key = buildSiteSpecificKey(FULL_SYNC_TIMESTAMP_KEY) + if (key != null) { + dataStore.edit { preferences -> + preferences[key] = timestamp.toString() + } + } + } + + suspend fun getFullSyncLastCompletedTimestamp(): Long? { + val key = buildSiteSpecificKey(FULL_SYNC_TIMESTAMP_KEY) + return if (key != null) { + dataStore.data.first()[key]?.toLongOrNull() + } else { + null + } + } + private fun buildSiteSpecificKey(key: String): Preferences.Key? { val site = selectedSite.getOrNull() return if (site != null) { @@ -94,5 +114,6 @@ class WooPosSyncTimestampRepository @Inject constructor( private companion object { const val PRODUCTS_TIMESTAMP_KEY = "pos_products_sync_timestamp" const val VARIATIONS_TIMESTAMP_KEY = "pos_variations_sync_timestamp" + const val FULL_SYNC_TIMESTAMP_KEY = "pos_full_sync_completed_timestamp" } } diff --git a/WooCommerce/src/main/res/drawable/ic_woo_pos_info_banner.xml b/WooCommerce/src/main/res/drawable/ic_woo_pos_info_banner.xml new file mode 100644 index 00000000000..af0dc4bf46c --- /dev/null +++ b/WooCommerce/src/main/res/drawable/ic_woo_pos_info_banner.xml @@ -0,0 +1,9 @@ + + + diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 0fe4e874c73..d1d3cc21e34 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -3601,6 +3601,14 @@ + Syncing product catalog + Unable to sync + We are unable to sync your product catalog. Please check your internet connection and retry. + Retry + Refresh catalog + The catalog hasn\'t been synced in the last 7 days. Either connect your device to WiFi or enable syncing over cellular network in POS settings. + Dismiss banner + Reader connected Connect your reader Check out diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModelTest.kt index 3281ab575c2..6d6cbf6f1d1 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModelTest.kt @@ -7,6 +7,7 @@ import com.woocommerce.android.ui.woopos.home.ParentToChildrenEvent.OrderSuccess import com.woocommerce.android.ui.woopos.home.WooPosHomeUIEvent.ExitPosClicked import com.woocommerce.android.ui.woopos.home.WooPosHomeUIEvent.SystemBackClicked import com.woocommerce.android.ui.woopos.home.items.WooPosItemsViewModel.ItemClickedData +import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformLocalCatalogIncrementalSync import com.woocommerce.android.ui.woopos.util.WooPosCoroutineTestRule import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.BackToCartTapped import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.ExitConfirmed @@ -37,6 +38,7 @@ class WooPosHomeViewModelTest { private val parentToChildrenEventSender: WooPosParentToChildrenEventSender = mock() private val analyticsTracker: WooPosAnalyticsTracker = mock() private val soundHelper: WooPosSoundHelper = mock() + private val incrementalSync: WooPosPerformLocalCatalogIncrementalSync = mock() @Test fun `when order created, then pass event to cart`() = @@ -341,11 +343,14 @@ class WooPosHomeViewModelTest { assertThat(viewModel.state.value.dialogState).isEqualTo(WooPosHomeState.DialogState.Hidden) } - private fun createViewModel() = WooPosHomeViewModel( - childrenToParentEventReceiver, - parentToChildrenEventSender, - analyticsTracker, - soundHelper, - SavedStateHandle() - ) + private fun createViewModel(): WooPosHomeViewModel { + return WooPosHomeViewModel( + childrenToParentEventReceiver, + parentToChildrenEventSender, + analyticsTracker, + soundHelper, + incrementalSync, + SavedStateHandle() + ) + } } 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 72aa0356291..47868930d46 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 @@ -9,6 +9,7 @@ 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.ItemClickedData import com.woocommerce.android.ui.woopos.home.items.coupons.creation.WooPosCouponCreationFacade +import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncStatusChecker import com.woocommerce.android.ui.woopos.util.WooPosCoroutineTestRule import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.SearchButtonTapped @@ -55,6 +56,7 @@ class WooPosItemsViewModelTest { private val fromChildToParentEventSender: WooPosChildrenToParentEventSender = mock() private val parentToChildrenEventReceiver: WooPosParentToChildrenEventReceiver = mock() private val preferencesRepository: WooPosPreferencesRepository = mock() + private val syncStatusChecker: WooPosFullSyncStatusChecker = mock() @Before fun setup() { @@ -457,6 +459,7 @@ class WooPosItemsViewModelTest { parentToChildrenEventReceiver = parentToChildrenEventReceiver, preferencesRepository = preferencesRepository, analyticsTracker = analyticsTracker, + syncStatusChecker = syncStatusChecker, ) } } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncStatusCheckerTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncStatusCheckerTest.kt new file mode 100644 index 00000000000..5433357393c --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncStatusCheckerTest.kt @@ -0,0 +1,243 @@ +package com.woocommerce.android.ui.woopos.localcatalog + +import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper +import com.woocommerce.android.ui.woopos.featureflags.WooPosLocalCatalogM1Enabled +import com.woocommerce.android.ui.woopos.util.WooPosCoroutineTestRule +import com.woocommerce.android.ui.woopos.util.WooPosNetworkStatus +import com.woocommerce.android.ui.woopos.util.datastore.WooPosSyncTimestampManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.LocalOrRemoteId +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogStore +import kotlin.test.Test +import kotlin.time.Duration.Companion.days + +@ExperimentalCoroutinesApi +class WooPosFullSyncStatusCheckerTest { + @Rule + @JvmField + val coroutinesTestRule = WooPosCoroutineTestRule() + + private val syncTimestampManager: WooPosSyncTimestampManager = mock() + private val selectedSite: SelectedSite = mock() + private val networkStatus: WooPosNetworkStatus = mock() + private val wooPosLocalCatalogM1Enabled: WooPosLocalCatalogM1Enabled = mock() + private val localCatalogStore: WooPosLocalCatalogStore = mock() + private val wooPosLogWrapper: WooPosLogWrapper = mock() + + private val siteModel = SiteModel().apply { + id = 123 + siteId = 456L + } + + private fun createSut() = WooPosFullSyncStatusChecker( + syncTimestampManager = syncTimestampManager, + selectedSite = selectedSite, + networkStatus = networkStatus, + wooPosLocalCatalogM1Enabled = wooPosLocalCatalogM1Enabled, + localCatalogStore = localCatalogStore, + wooPosLogWrapper = wooPosLogWrapper + ) + + @Test + fun `given feature flag disabled, when checkSyncRequirement called, then should return NotRequired`() = runTest { + // GIVEN + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(false) + + val sut = createSut() + + // WHEN + val result = sut.checkSyncRequirement() + + // THEN + assertThat(result).isEqualTo(WooPosFullSyncRequirement.NotRequired) + } + + @Test + fun `given no site selected, when checkSyncRequirement called, then should return Error`() = runTest { + // GIVEN + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(null) + + val sut = createSut() + + // WHEN + val result = sut.checkSyncRequirement() + + // THEN + assertThat(result).isInstanceOf(WooPosFullSyncRequirement.Error::class.java) + assertThat((result as WooPosFullSyncRequirement.Error).message).isEqualTo("No site selected") + } + + @Test + fun `given never synced before and network connected, when checkSyncRequirement called, then should return BlockingRequired`() = + runTest { + // GIVEN + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(siteModel) + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(null) + whenever(networkStatus.isConnected()).thenReturn(true) + whenever(localCatalogStore.getProductCount(any())).thenReturn(Result.success(0)) + + val sut = createSut() + + // WHEN + val result = sut.checkSyncRequirement() + + // THEN + assertThat(result).isEqualTo(WooPosFullSyncRequirement.BlockingRequired) + } + + @Test + fun `given never synced before and no network, when checkSyncRequirement called, then should return Error`() = + runTest { + // GIVEN + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(siteModel) + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(null) + whenever(networkStatus.isConnected()).thenReturn(false) + whenever(localCatalogStore.getProductCount(any())).thenReturn(Result.success(0)) + + val sut = createSut() + + // WHEN + val result = sut.checkSyncRequirement() + + // THEN + assertThat(result).isInstanceOf(WooPosFullSyncRequirement.Error::class.java) + assertThat((result as WooPosFullSyncRequirement.Error).message).isEqualTo("No network connection") + } + + @Test + fun `given sync overdue and network connected, when checkSyncRequirement called, then should return Overdue`() = + runTest { + // GIVEN + val overdueTimestamp = System.currentTimeMillis() - 8.days.inWholeMilliseconds + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(siteModel) + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(overdueTimestamp) + whenever(networkStatus.isConnected()).thenReturn(true) + whenever(localCatalogStore.getProductCount(LocalOrRemoteId.LocalId(siteModel.id))) + .thenReturn(Result.success(10)) + + val sut = createSut() + + // WHEN + val result = sut.checkSyncRequirement() + + // THEN + assertThat(result).isEqualTo(WooPosFullSyncRequirement.Overdue) + } + + @Test + fun `given sync overdue and no network, when checkSyncRequirement called, then should return Overdue`() = + runTest { + // GIVEN + val overdueTimestamp = System.currentTimeMillis() - 8.days.inWholeMilliseconds + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(siteModel) + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(overdueTimestamp) + whenever(networkStatus.isConnected()).thenReturn(false) + whenever(localCatalogStore.getProductCount(LocalOrRemoteId.LocalId(siteModel.id))) + .thenReturn(Result.success(5)) + + val sut = createSut() + + // WHEN + val result = sut.checkSyncRequirement() + + // THEN + assertThat(result).isEqualTo(WooPosFullSyncRequirement.Overdue) + } + + @Test + fun `given sync overdue with empty catalog and no network, when checkSyncRequirement called, then should return Overdue`() = + runTest { + // GIVEN + val overdueTimestamp = System.currentTimeMillis() - 8.days.inWholeMilliseconds + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(siteModel) + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(overdueTimestamp) + whenever(networkStatus.isConnected()).thenReturn(false) + whenever(localCatalogStore.getProductCount(LocalOrRemoteId.LocalId(siteModel.id))) + .thenReturn(Result.success(0)) + + val sut = createSut() + + // WHEN + val result = sut.checkSyncRequirement() + + // THEN + assertThat(result).isEqualTo(WooPosFullSyncRequirement.Overdue) + } + + @Test + fun `given sync not overdue, when checkSyncRequirement called, then should return NotRequired`() = + runTest { + // GIVEN + val recentTimestamp = System.currentTimeMillis() - 1.days.inWholeMilliseconds + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(siteModel) + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(recentTimestamp) + whenever(networkStatus.isConnected()).thenReturn(true) + whenever(localCatalogStore.getProductCount(LocalOrRemoteId.LocalId(siteModel.id))) + .thenReturn(Result.success(15)) + + val sut = createSut() + + // WHEN + val result = sut.checkSyncRequirement() + + // THEN + assertThat(result).isEqualTo(WooPosFullSyncRequirement.NotRequired) + } + + @Test + fun `given sync at exact threshold, when checkSyncRequirement called, then should return Overdue`() = + runTest { + // GIVEN + val exactThresholdTimestamp = System.currentTimeMillis() - 7.days.inWholeMilliseconds + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(siteModel) + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(exactThresholdTimestamp) + whenever(networkStatus.isConnected()).thenReturn(true) + whenever(localCatalogStore.getProductCount(LocalOrRemoteId.LocalId(siteModel.id))) + .thenReturn(Result.success(20)) + + val sut = createSut() + + // WHEN + val result = sut.checkSyncRequirement() + + // THEN + assertThat(result).isEqualTo(WooPosFullSyncRequirement.Overdue) + } + + @Test + fun `given product count fetch fails, when checkSyncRequirement called, then should treat as empty catalog`() = + runTest { + // GIVEN + val recentTimestamp = System.currentTimeMillis() - 1.days.inWholeMilliseconds + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(siteModel) + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(recentTimestamp) + whenever(networkStatus.isConnected()).thenReturn(true) + whenever(localCatalogStore.getProductCount(LocalOrRemoteId.LocalId(siteModel.id))) + .thenReturn(Result.failure(Exception("Database error"))) + + val sut = createSut() + + // WHEN + val result = sut.checkSyncRequirement() + + // THEN + assertThat(result).isEqualTo(WooPosFullSyncRequirement.NotRequired) + } +} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncWorkerTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncWorkerTest.kt index f2de1bc2fee..7758a7f9197 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncWorkerTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosLocalCatalogSyncWorkerTest.kt @@ -8,6 +8,7 @@ import com.woocommerce.android.ui.login.AccountRepository import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper import com.woocommerce.android.ui.woopos.featureflags.WooPosLocalCatalogM1Enabled import com.woocommerce.android.ui.woopos.util.datastore.WooPosPreferencesRepository +import com.woocommerce.android.ui.woopos.util.datastore.WooPosSyncTimestampManager import com.woocommerce.android.viewmodel.BaseUnitTest import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat @@ -29,6 +30,7 @@ class WooPosLocalCatalogSyncWorkerTest : BaseUnitTest() { private var accountRepository: AccountRepository = mock() private var selectedSite: SelectedSite = mock() private var syncRepository: WooPosLocalCatalogSyncRepository = mock() + private var timestampManager: WooPosSyncTimestampManager = mock() private lateinit var site: SiteModel private var logger: WooPosLogWrapper = mock() private var featureFlagM1Enabled: WooPosLocalCatalogM1Enabled = mock() @@ -76,6 +78,7 @@ class WooPosLocalCatalogSyncWorkerTest : BaseUnitTest() { accountRepository = accountRepository, selectedSite = selectedSite, syncRepository = syncRepository, + timestampManager = timestampManager, logger = logger, featureFlagM1Enabled = featureFlagM1Enabled, preferencesRepository = preferencesRepository, @@ -96,6 +99,19 @@ class WooPosLocalCatalogSyncWorkerTest : BaseUnitTest() { verify(syncRepository).syncLocalCatalogIncremental(eq(site)) } + @Test + fun `given successful sync, when doWork completes, then stores full sync completion timestamp`() = testBlocking { + // GIVEN + val worker = createWorker() + + // WHEN + val result = worker.doWork() + + // THEN + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + verify(timestampManager).storeFullSyncLastCompletedTimestamp(any()) + } + @Test fun `when feature flag disabled, then returns failure`() = testBlocking { // GIVEN diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformFullCatalogSyncUseCaseTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformFullCatalogSyncUseCaseTest.kt new file mode 100644 index 00000000000..a73bf11422b --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformFullCatalogSyncUseCaseTest.kt @@ -0,0 +1,99 @@ +package com.woocommerce.android.ui.woopos.localcatalog + +import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper +import com.woocommerce.android.ui.woopos.util.datastore.WooPosSyncTimestampManager +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel + +class WooPosPerformFullCatalogSyncUseCaseTest { + + private val syncRepository: WooPosLocalCatalogSyncRepository = mock() + private val syncTimestampManager: WooPosSyncTimestampManager = mock() + private val selectedSite: SelectedSite = mock() + private val syncScheduler: WooPosLocalCatalogSyncScheduler = mock() + private val wooPosLogWrapper: WooPosLogWrapper = mock() + + private val useCase = WooPosPerformInstantCatalogFullSync( + syncRepository = syncRepository, + syncTimestampManager = syncTimestampManager, + syncScheduler = syncScheduler, + selectedSite = selectedSite, + wooPosLogWrapper = wooPosLogWrapper + ) + + @Test + fun `given no site selected, when invoke called, then returns Failed`() = runTest { + // GIVEN + whenever(selectedSite.getOrNull()).thenReturn(null) + whenever(syncScheduler.observeOneTimeWorkStatus()).thenReturn(flowOf(false)) + whenever(syncScheduler.observePeriodicWorkStatus()).thenReturn(flowOf(false)) + + // WHEN + val result = useCase().first() + + // THEN + assertThat(result).isInstanceOf(WooPosFullSyncState.Failed::class.java) + assertThat((result as WooPosFullSyncState.Failed).error).isEqualTo("No site selected") + } + + @Test + fun `given sync succeeds, when invoke called, then blocks and returns Success`() = runTest { + // GIVEN + val site = SiteModel().apply { id = 123 } + val syncResult = PosLocalCatalogSyncResult.Success( + productsSynced = 10, + variationsSynced = 5, + syncDurationMs = 1000L + ) + + whenever(selectedSite.getOrNull()).thenReturn(site) + whenever(syncScheduler.observeOneTimeWorkStatus()).thenReturn(flowOf(false)) + whenever(syncScheduler.observePeriodicWorkStatus()).thenReturn(flowOf(false)) + whenever(syncRepository.syncLocalCatalogFull(site)).thenReturn(syncResult) + + // WHEN + val results = mutableListOf() + useCase().collect { status -> + results.add(status) + } + + // THEN + assertThat(results).hasSize(2) + assertThat(results[0]).isEqualTo(WooPosFullSyncState.InProgress) + assertThat(results[1]).isEqualTo(WooPosFullSyncState.Success) + verify(syncTimestampManager).storeFullSyncLastCompletedTimestamp(any()) + } + + @Test + fun `given sync fails, when invoke called, then blocks and returns Failed`() = runTest { + // GIVEN + val site = SiteModel().apply { id = 123 } + val syncResult = PosLocalCatalogSyncResult.Failure.UnexpectedError("Network error") + + whenever(selectedSite.getOrNull()).thenReturn(site) + whenever(syncScheduler.observeOneTimeWorkStatus()).thenReturn(flowOf(false)) + whenever(syncScheduler.observePeriodicWorkStatus()).thenReturn(flowOf(false)) + whenever(syncRepository.syncLocalCatalogFull(site)).thenReturn(syncResult) + + // WHEN + val results = mutableListOf() + useCase().collect { status -> + results.add(status) + } + + // THEN + assertThat(results).hasSize(2) + assertThat(results[0]).isEqualTo(WooPosFullSyncState.InProgress) + assertThat(results[1]).isInstanceOf(WooPosFullSyncState.Failed::class.java) + assertThat((results[1] as WooPosFullSyncState.Failed).error).isEqualTo("Network error") + } +} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModelTest.kt index 92477b39ac3..d863efb8583 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModelTest.kt @@ -2,7 +2,8 @@ package com.woocommerce.android.ui.woopos.splash import com.woocommerce.android.ui.woopos.common.data.WooPosPopularProductsProvider import com.woocommerce.android.ui.woopos.home.items.products.WooPosProductsDataSource -import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformLocalCatalogIncrementalSync +import com.woocommerce.android.ui.woopos.localcatalog.WooPosLocalCatalogInitialFullSyncState +import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformLocalCatalogInitialFullSync import com.woocommerce.android.ui.woopos.orders.WooPosOrdersInMemoryCache import com.woocommerce.android.ui.woopos.tab.WooPosCanBeLaunchedInTab import com.woocommerce.android.ui.woopos.tab.WooPosLaunchability @@ -10,6 +11,7 @@ import com.woocommerce.android.ui.woopos.util.WooPosCoroutineTestRule import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -29,7 +31,7 @@ class WooPosSplashViewModelTest { private val analyticsTracker: WooPosAnalyticsTracker = mock() private val popularProductsProvider: WooPosPopularProductsProvider = mock() private val posCanBeLaunchedInTab: WooPosCanBeLaunchedInTab = mock() - private val performIncrementalSyncUseCase: WooPosPerformLocalCatalogIncrementalSync = mock() + private val performInitialFullSync: WooPosPerformLocalCatalogInitialFullSync = mock() @Rule @JvmField @@ -154,12 +156,16 @@ class WooPosSplashViewModelTest { assertThat(sut.state.value).isEqualTo(WooPosSplashState.Loaded) } - private fun createSut() = WooPosSplashViewModel( - productsDataSource, - popularProductsProvider, - analyticsTracker, - posCanBeLaunchedInTab, - ordersCache, - performIncrementalSyncUseCase - ) + private fun createSut(): WooPosSplashViewModel { + whenever(performInitialFullSync()).thenReturn(flowOf(WooPosLocalCatalogInitialFullSyncState.NotRequired)) + + return WooPosSplashViewModel( + productsDataSource, + popularProductsProvider, + analyticsTracker, + posCanBeLaunchedInTab, + ordersCache, + performInitialFullSync, + ) + } } diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/pos/WooPosProductsDao.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/pos/WooPosProductsDao.kt index 89c610b99a8..776ec1be171 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/pos/WooPosProductsDao.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/pos/WooPosProductsDao.kt @@ -30,6 +30,9 @@ abstract class WooPosProductsDao { @Query("SELECT * FROM PosProductEntity WHERE localSiteId = :localSiteId AND remoteId = :remoteId") abstract suspend fun getProduct(localSiteId: LocalId, remoteId: RemoteId): WooPosProductEntity? + @Query("SELECT COUNT(*) FROM PosProductEntity WHERE localSiteId = :localSiteId") + abstract suspend fun getProductCount(localSiteId: LocalId): Int + @Upsert abstract suspend fun upsertProducts(products: List) diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/pos/localcatalog/WooPosLocalCatalogStore.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/pos/localcatalog/WooPosLocalCatalogStore.kt index 574c3bf0b44..4db7ff4e1c5 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/pos/localcatalog/WooPosLocalCatalogStore.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/store/pos/localcatalog/WooPosLocalCatalogStore.kt @@ -64,6 +64,20 @@ class WooPosLocalCatalogStore @Inject constructor( Result.success(product) } + /** + * Gets the count of products in the local database for a given site. + * + * @param [siteId] The local site ID + * @return Result containing the product count or error + */ + suspend fun getProductCount( + siteId: LocalOrRemoteId.LocalId + ): Result = + coroutineEngine.withDefaultContext(API, this, "getProductCount") { + val count = posProductDao.getProductCount(siteId) + Result.success(count) + } + /** * Executes a block of code within a database transaction. * If the block throws an exception, the transaction is rolled back.