From 32eaa62d12be5e9cddd53287724b4ce2dd0a2633 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 2 Oct 2025 12:03:00 +0200 Subject: [PATCH 01/46] Allow storing last full sync timestamp --- .../datastore/WooPosSyncTimestampManager.kt | 6 +++++ .../WooPosSyncTimestampRepository.kt | 23 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) 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 b5151fb31c3c..594cd5c73a2e 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 5f3ea4292325..3298a06c5ff0 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]?.toLong() + } 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" } } From 5aec9de7612a7d28ec6a33b0473a23b7035df6ce Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 2 Oct 2025 12:52:34 +0200 Subject: [PATCH 02/46] Store full sync timestamp --- .../localcatalog/WooPosLocalCatalogSyncWorker.kt | 3 +++ .../WooPosLocalCatalogSyncWorkerTest.kt | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) 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 e09e08fd97b1..db92efd6f6ed 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 @@ -8,6 +8,7 @@ import com.woocommerce.android.tools.SelectedSite 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.WooPosSyncTimestampManager import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -21,6 +22,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, ) : CoroutineWorker(appContext, workerParams) { @@ -56,6 +58,7 @@ constructor( "Local catalog sync completed successfully. Products: ${syncResult.productsSynced}, " + "Variations: ${syncResult.variationsSynced}, Duration: ${syncResult.syncDurationMs}ms" ) + timestampManager.storeFullSyncLastCompletedTimestamp(System.currentTimeMillis()) Result.success() } 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 2485895fa579..9b3222dda183 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 @@ -7,6 +7,7 @@ import com.woocommerce.android.tools.SelectedSite 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.WooPosSyncTimestampManager import com.woocommerce.android.viewmodel.BaseUnitTest import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat @@ -27,6 +28,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() @@ -64,6 +66,7 @@ class WooPosLocalCatalogSyncWorkerTest : BaseUnitTest() { accountRepository = accountRepository, selectedSite = selectedSite, syncRepository = syncRepository, + timestampManager = timestampManager, logger = logger, featureFlagM1Enabled = featureFlagM1Enabled, ) @@ -81,6 +84,19 @@ class WooPosLocalCatalogSyncWorkerTest : BaseUnitTest() { assertThat(result).isEqualTo(ListenableWorker.Result.success()) } + @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 From df96e1531e49569a20eee19d762a2962e1ae002c Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 2 Oct 2025 12:54:38 +0200 Subject: [PATCH 03/46] Check if full sync is overdue --- .../com/woocommerce/android/AppInitializer.kt | 6 + .../WooPosFullSyncCheckUseCase.kt | 59 +++++++ .../WooPosFullSyncCheckUseCaseTest.kt | 151 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCase.kt create mode 100644 WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt index 6e6a797b0f8c..293778ae11ae 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt @@ -45,6 +45,7 @@ import com.woocommerce.android.ui.login.AccountRepository import com.woocommerce.android.ui.main.MainActivity import com.woocommerce.android.ui.payments.cardreader.onboarding.CardReaderOnboardingChecker import com.woocommerce.android.ui.woopos.localcatalog.WooPosLocalCatalogSyncScheduler +import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncCheckUseCase import com.woocommerce.android.util.AppThemeUtils import com.woocommerce.android.util.ApplicationEdgeToEdgeEnabler import com.woocommerce.android.util.ApplicationLifecycleMonitor @@ -165,6 +166,8 @@ class AppInitializer @Inject constructor() : ApplicationLifecycleListener { @Inject lateinit var posLocalCatalogScheduler: WooPosLocalCatalogSyncScheduler + @Inject lateinit var posCatalogSyncCheck: WooPosFullSyncCheckUseCase + private var connectionReceiverRegistered = false private lateinit var application: Application @@ -269,6 +272,9 @@ class AppInitializer @Inject constructor() : ApplicationLifecycleListener { appCoroutineScope.launch { registerDevice(IF_NEEDED) + + // Check if full sync was done in last 24h and trigger immediate sync if needed + posCatalogSyncCheck.checkAndTriggerSyncIfNeeded() } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCase.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCase.kt new file mode 100644 index 000000000000..8b576769ffae --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCase.kt @@ -0,0 +1,59 @@ +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 javax.inject.Inject + +class WooPosFullSyncCheckUseCase @Inject constructor( + private val syncTimestampManager: WooPosSyncTimestampManager, + private val syncScheduler: WooPosLocalCatalogSyncScheduler, + private val selectedSite: SelectedSite, + private val networkStatus: WooPosNetworkStatus, + private val wooPosLocalCatalogM1Enabled: WooPosLocalCatalogM1Enabled, + private val wooPosLogWrapper: WooPosLogWrapper +) { + suspend fun checkAndTriggerSyncIfNeeded() { + if (!wooPosLocalCatalogM1Enabled()) { + wooPosLogWrapper.d("Local catalog feature not enabled") + return + } + + if (selectedSite.getOrNull() == null) { + wooPosLogWrapper.d("No site selected") + return + } + + if (!networkStatus.isConnected()) { + wooPosLogWrapper.d("No network connection") + return + } + + if (syncScheduler.isPeriodicWorkRunning() || syncScheduler.isOneTimeWorkRunning()) { + wooPosLogWrapper.d("Sync already running") + return + } + + val lastFullSyncTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() + val currentTime = System.currentTimeMillis() + val twentyFourHoursInMillis = 24 * 60 * 60 * 1000L + + if (lastFullSyncTimestamp == null) { + wooPosLogWrapper.i("No previous full sync found - triggering immediate sync") + syncScheduler.triggerManualFullCatalogSync() + return + } + + val timeSinceLastSync = currentTime - lastFullSyncTimestamp + if (timeSinceLastSync > twentyFourHoursInMillis) { + val hoursSinceSync = timeSinceLastSync / (60 * 60 * 1000) + wooPosLogWrapper.i("Last full sync was $hoursSinceSync hours ago - triggering immediate sync") + syncScheduler.triggerManualFullCatalogSync() + } else { + val hoursUntilNext = (twentyFourHoursInMillis - timeSinceLastSync) / (60 * 60 * 1000) + wooPosLogWrapper.d("Full sync is up to date - next sync needed in $hoursUntilNext hours") + } + } +} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt new file mode 100644 index 000000000000..ffeaec274bd8 --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt @@ -0,0 +1,151 @@ +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.junit.Rule +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import kotlin.test.Test + +@ExperimentalCoroutinesApi +class WooPosFullSyncCheckUseCaseTest { + + @Rule + @JvmField + val coroutinesTestRule = WooPosCoroutineTestRule() + + private val syncTimestampManager: WooPosSyncTimestampManager = mock() + private val syncScheduler: WooPosLocalCatalogSyncScheduler = mock() + private val selectedSite: SelectedSite = mock() + private val networkStatus: WooPosNetworkStatus = mock() + private val wooPosLocalCatalogM1Enabled: WooPosLocalCatalogM1Enabled = mock() + private val wooPosLogWrapper: WooPosLogWrapper = mock() + + private val useCase = WooPosFullSyncCheckUseCase( + syncTimestampManager = syncTimestampManager, + syncScheduler = syncScheduler, + selectedSite = selectedSite, + networkStatus = networkStatus, + wooPosLocalCatalogM1Enabled = wooPosLocalCatalogM1Enabled, + wooPosLogWrapper = wooPosLogWrapper + ) + + @Test + fun `given feature flag disabled, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = + runTest { + // Given + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(false) + + // When + useCase.checkAndTriggerSyncIfNeeded() + + // Then + verify(syncScheduler, never()).triggerManualFullCatalogSync() + } + + @Test + fun `given no site selected, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = + runTest { + // Given + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(null) + + // When + useCase.checkAndTriggerSyncIfNeeded() + + // Then + verify(syncScheduler, never()).triggerManualFullCatalogSync() + } + + @Test + fun `given no network connection, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = + runTest { + // Given + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(mock()) + whenever(networkStatus.isConnected()).thenReturn(false) + + // When + useCase.checkAndTriggerSyncIfNeeded() + + // Then + verify(syncScheduler, never()).triggerManualFullCatalogSync() + } + + @Test + fun `given sync is running, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = + runTest { + // Given + givenAllPrerequisitesMet() + whenever(syncScheduler.isPeriodicWorkRunning()).thenReturn(true) + + // When + useCase.checkAndTriggerSyncIfNeeded() + + // Then + verify(syncScheduler, never()).triggerManualFullCatalogSync() + } + + @Test + fun `given no previous sync, when checkAndTriggerSyncIfNeeded called, then triggers sync`() = + runTest { + // Given + givenAllPrerequisitesMet() + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(null) + + // When + useCase.checkAndTriggerSyncIfNeeded() + + // Then + verify(syncScheduler).triggerManualFullCatalogSync() + } + + @Test + fun `given sync older than 24 hours, when checkAndTriggerSyncIfNeeded called, then triggers sync`() = + runTest { + // Given + val currentTime = System.currentTimeMillis() + val twentyFiveHoursAgo = currentTime - (25 * 60 * 60 * 1000L) + givenAllPrerequisitesMet() + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(twentyFiveHoursAgo) + + // When + useCase.checkAndTriggerSyncIfNeeded() + + // Then + verify(syncScheduler).triggerManualFullCatalogSync() + } + + @Test + fun `given sync newer than 24 hours, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = + runTest { + // Given + val currentTime = System.currentTimeMillis() + val twoHoursAgo = currentTime - (2 * 60 * 60 * 1000L) + givenAllPrerequisitesMet() + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(twoHoursAgo) + + // When + useCase.checkAndTriggerSyncIfNeeded() + + // Then + verify(syncScheduler, never()).triggerManualFullCatalogSync() + } + + private fun givenAllPrerequisitesMet() { + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(mock()) + whenever(networkStatus.isConnected()).thenReturn(true) + whenever(syncScheduler.isPeriodicWorkRunning()).thenReturn(false) + whenever(syncScheduler.isOneTimeWorkRunning()).thenReturn(false) + } +} \ No newline at end of file From 7dd09947660d1681af5d1678601ce980dec382eb Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 2 Oct 2025 13:34:19 +0200 Subject: [PATCH 04/46] Satisfy detekt's complaints --- .../com/woocommerce/android/AppInitializer.kt | 2 +- .../localcatalog/WooPosFullSyncCheckUseCase.kt | 14 ++++++++++---- .../localcatalog/WooPosFullSyncCheckUseCaseTest.kt | 12 +++++++++--- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt index 293778ae11ae..fa2fe7b0fb80 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt @@ -44,8 +44,8 @@ import com.woocommerce.android.ui.jitm.JitmStoreInMemoryCache import com.woocommerce.android.ui.login.AccountRepository import com.woocommerce.android.ui.main.MainActivity import com.woocommerce.android.ui.payments.cardreader.onboarding.CardReaderOnboardingChecker -import com.woocommerce.android.ui.woopos.localcatalog.WooPosLocalCatalogSyncScheduler import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncCheckUseCase +import com.woocommerce.android.ui.woopos.localcatalog.WooPosLocalCatalogSyncScheduler import com.woocommerce.android.util.AppThemeUtils import com.woocommerce.android.util.ApplicationEdgeToEdgeEnabler import com.woocommerce.android.util.ApplicationLifecycleMonitor diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCase.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCase.kt index 8b576769ffae..f6d1061811fa 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCase.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCase.kt @@ -5,6 +5,7 @@ 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 java.util.concurrent.TimeUnit import javax.inject.Inject class WooPosFullSyncCheckUseCase @Inject constructor( @@ -15,6 +16,12 @@ class WooPosFullSyncCheckUseCase @Inject constructor( private val wooPosLocalCatalogM1Enabled: WooPosLocalCatalogM1Enabled, private val wooPosLogWrapper: WooPosLogWrapper ) { + companion object { + private const val FULL_SYNC_INTERVAL_HOURS = 24L + private val FULL_SYNC_INTERVAL_MILLIS = TimeUnit.HOURS.toMillis(FULL_SYNC_INTERVAL_HOURS) + } + + @Suppress("ReturnCount") suspend fun checkAndTriggerSyncIfNeeded() { if (!wooPosLocalCatalogM1Enabled()) { wooPosLogWrapper.d("Local catalog feature not enabled") @@ -38,7 +45,6 @@ class WooPosFullSyncCheckUseCase @Inject constructor( val lastFullSyncTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() val currentTime = System.currentTimeMillis() - val twentyFourHoursInMillis = 24 * 60 * 60 * 1000L if (lastFullSyncTimestamp == null) { wooPosLogWrapper.i("No previous full sync found - triggering immediate sync") @@ -47,12 +53,12 @@ class WooPosFullSyncCheckUseCase @Inject constructor( } val timeSinceLastSync = currentTime - lastFullSyncTimestamp - if (timeSinceLastSync > twentyFourHoursInMillis) { - val hoursSinceSync = timeSinceLastSync / (60 * 60 * 1000) + if (timeSinceLastSync > FULL_SYNC_INTERVAL_MILLIS) { + val hoursSinceSync = TimeUnit.MILLISECONDS.toHours(timeSinceLastSync) wooPosLogWrapper.i("Last full sync was $hoursSinceSync hours ago - triggering immediate sync") syncScheduler.triggerManualFullCatalogSync() } else { - val hoursUntilNext = (twentyFourHoursInMillis - timeSinceLastSync) / (60 * 60 * 1000) + val hoursUntilNext = TimeUnit.MILLISECONDS.toHours(FULL_SYNC_INTERVAL_MILLIS - timeSinceLastSync) wooPosLogWrapper.d("Full sync is up to date - next sync needed in $hoursUntilNext hours") } } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt index ffeaec274bd8..e0247468dc4f 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt @@ -14,6 +14,7 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.fluxc.model.SiteModel +import java.util.concurrent.TimeUnit import kotlin.test.Test @ExperimentalCoroutinesApi @@ -23,6 +24,11 @@ class WooPosFullSyncCheckUseCaseTest { @JvmField val coroutinesTestRule = WooPosCoroutineTestRule() + companion object { + private val TWENTY_FIVE_HOURS_MILLIS = TimeUnit.HOURS.toMillis(25) + private val TWO_HOURS_MILLIS = TimeUnit.HOURS.toMillis(2) + } + private val syncTimestampManager: WooPosSyncTimestampManager = mock() private val syncScheduler: WooPosLocalCatalogSyncScheduler = mock() private val selectedSite: SelectedSite = mock() @@ -114,7 +120,7 @@ class WooPosFullSyncCheckUseCaseTest { runTest { // Given val currentTime = System.currentTimeMillis() - val twentyFiveHoursAgo = currentTime - (25 * 60 * 60 * 1000L) + val twentyFiveHoursAgo = currentTime - TWENTY_FIVE_HOURS_MILLIS givenAllPrerequisitesMet() whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(twentyFiveHoursAgo) @@ -130,7 +136,7 @@ class WooPosFullSyncCheckUseCaseTest { runTest { // Given val currentTime = System.currentTimeMillis() - val twoHoursAgo = currentTime - (2 * 60 * 60 * 1000L) + val twoHoursAgo = currentTime - TWO_HOURS_MILLIS givenAllPrerequisitesMet() whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(twoHoursAgo) @@ -148,4 +154,4 @@ class WooPosFullSyncCheckUseCaseTest { whenever(syncScheduler.isPeriodicWorkRunning()).thenReturn(false) whenever(syncScheduler.isOneTimeWorkRunning()).thenReturn(false) } -} \ No newline at end of file +} From 925d66889bc3b8b8994ebaaba7a9b8c4e0339f15 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 2 Oct 2025 18:39:29 +0200 Subject: [PATCH 05/46] Clean up code --- .../src/main/kotlin/com/woocommerce/android/AppInitializer.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt index fa2fe7b0fb80..31796488de63 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt @@ -273,7 +273,6 @@ class AppInitializer @Inject constructor() : ApplicationLifecycleListener { appCoroutineScope.launch { registerDevice(IF_NEEDED) - // Check if full sync was done in last 24h and trigger immediate sync if needed posCatalogSyncCheck.checkAndTriggerSyncIfNeeded() } } From f6edb88c40619a4b9a25ed2ccd1fa2099135441f Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 2 Oct 2025 18:44:08 +0200 Subject: [PATCH 06/46] Clean up code --- .../WooPosFullSyncCheckUseCaseTest.kt | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt index e0247468dc4f..a64b644a2d03 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt @@ -48,102 +48,102 @@ class WooPosFullSyncCheckUseCaseTest { @Test fun `given feature flag disabled, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = runTest { - // Given + // GIVEN whenever(wooPosLocalCatalogM1Enabled()).thenReturn(false) - // When + // WHEN useCase.checkAndTriggerSyncIfNeeded() - // Then + // THEN verify(syncScheduler, never()).triggerManualFullCatalogSync() } @Test fun `given no site selected, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = runTest { - // Given + // GIVEN whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) whenever(selectedSite.getOrNull()).thenReturn(null) - // When + // WHEN useCase.checkAndTriggerSyncIfNeeded() - // Then + // THEN verify(syncScheduler, never()).triggerManualFullCatalogSync() } @Test fun `given no network connection, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = runTest { - // Given + // GIVEN whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) whenever(selectedSite.getOrNull()).thenReturn(mock()) whenever(networkStatus.isConnected()).thenReturn(false) - // When + // WHEN useCase.checkAndTriggerSyncIfNeeded() - // Then + // THEN verify(syncScheduler, never()).triggerManualFullCatalogSync() } @Test fun `given sync is running, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = runTest { - // Given + // GIVEN givenAllPrerequisitesMet() whenever(syncScheduler.isPeriodicWorkRunning()).thenReturn(true) - // When + // WHEN useCase.checkAndTriggerSyncIfNeeded() - // Then + // THEN verify(syncScheduler, never()).triggerManualFullCatalogSync() } @Test fun `given no previous sync, when checkAndTriggerSyncIfNeeded called, then triggers sync`() = runTest { - // Given + // GIVEN givenAllPrerequisitesMet() whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(null) - // When + // WHEN useCase.checkAndTriggerSyncIfNeeded() - // Then + // THEN verify(syncScheduler).triggerManualFullCatalogSync() } @Test fun `given sync older than 24 hours, when checkAndTriggerSyncIfNeeded called, then triggers sync`() = runTest { - // Given + // GIVEN val currentTime = System.currentTimeMillis() val twentyFiveHoursAgo = currentTime - TWENTY_FIVE_HOURS_MILLIS givenAllPrerequisitesMet() whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(twentyFiveHoursAgo) - // When + // WHEN useCase.checkAndTriggerSyncIfNeeded() - // Then + // THEN verify(syncScheduler).triggerManualFullCatalogSync() } @Test fun `given sync newer than 24 hours, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = runTest { - // Given + // GIVEN val currentTime = System.currentTimeMillis() val twoHoursAgo = currentTime - TWO_HOURS_MILLIS givenAllPrerequisitesMet() whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(twoHoursAgo) - // When + // WHEN useCase.checkAndTriggerSyncIfNeeded() - // Then + // THEN verify(syncScheduler, never()).triggerManualFullCatalogSync() } From 15df1130749c48802b69315a89e797ad397f1edd Mon Sep 17 00:00:00 2001 From: samiuelson Date: Fri, 3 Oct 2025 14:25:25 +0200 Subject: [PATCH 07/46] Add function to check if local catalog is empty --- .../android/fluxc/persistence/dao/pos/WooPosProductsDao.kt | 3 +++ 1 file changed, 3 insertions(+) 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 d18d33aed102..8ad1d9be65ac 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 @@ -16,6 +16,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) From 144d9deab2cff398fa44b9be93943801c5d31d00 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Fri, 3 Oct 2025 14:26:37 +0200 Subject: [PATCH 08/46] Add getProductCount method --- .../pos/localcatalog/WooPosLocalCatalogStore.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 510045c21ea0..d5d4d0dc5dfc 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. From 941672f7a16b18641066970923f498d42c51bcff Mon Sep 17 00:00:00 2001 From: samiuelson Date: Fri, 3 Oct 2025 14:27:54 +0200 Subject: [PATCH 09/46] Execute one-time full sync if needed before opening POS --- .../ui/woopos/home/WooPosHomeScreen.kt | 18 +- .../android/ui/woopos/home/WooPosHomeState.kt | 16 ++ .../ui/woopos/home/WooPosHomeUIEvent.kt | 1 + .../ui/woopos/home/WooPosHomeViewModel.kt | 37 ++++ .../ui/woopos/home/items/WooPosItemsScreen.kt | 98 ++++++++- .../WooPosPerformInitialCatalogFullSync.kt | 164 ++++++++++++++ WooCommerce/src/main/res/values/strings.xml | 5 + .../ui/woopos/home/WooPosHomeViewModelTest.kt | 22 +- ...WooPosPerformFullCatalogSyncUseCaseTest.kt | 207 ++++++++++++++++++ 9 files changed, 557 insertions(+), 11 deletions(-) create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformInitialCatalogFullSync.kt create mode 100644 WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformFullCatalogSyncUseCaseTest.kt diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt index 0a4c631cf69e..573e08e61e31 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt @@ -116,6 +116,7 @@ private fun WooPosHomeScreen( cartWidthDp = cartWidthDp, totalsWidthDp = totalsWidthAnimatedDp, onHomeUIEvent = onHomeUIEvent, + onRetryCatalogSyncClicked = { onHomeUIEvent(WooPosHomeUIEvent.RetryCatalogSyncClicked) }, ) } @@ -127,6 +128,7 @@ private fun WooPosHomeScreen( cartWidthDp: Dp, totalsWidthDp: Dp, onHomeUIEvent: (WooPosHomeUIEvent) -> Unit, + onRetryCatalogSyncClicked: () -> Unit = {}, ) { Box( modifier = Modifier @@ -149,7 +151,9 @@ private fun WooPosHomeScreen( ) { WooPosHomeScreenProducts( modifier = Modifier - .width(productsWidthDp) + .width(productsWidthDp), + catalogSyncState = state.catalogSyncState, + onRetryCatalogSyncClicked = onRetryCatalogSyncClicked ) WooPosHomeScreenCart( modifier = Modifier @@ -195,11 +199,19 @@ private fun Dialogs( } @Composable -private fun WooPosHomeScreenProducts(modifier: Modifier) { +private fun WooPosHomeScreenProducts( + modifier: Modifier, + catalogSyncState: WooPosHomeState.CatalogSyncState = WooPosHomeState.CatalogSyncState.Idle, + onRetryCatalogSyncClicked: () -> Unit = {} +) { if (isPreviewMode()) { WooPosItemsScreenPreview(modifier) } else { - WooPosItemsScreen(modifier = modifier) + WooPosItemsScreen( + modifier = modifier, + catalogSyncState = catalogSyncState, + onRetryCatalogSyncClicked = onRetryCatalogSyncClicked + ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt index 2c3fb16b1add..5a6f96470389 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt @@ -9,6 +9,7 @@ import kotlinx.parcelize.Parcelize data class WooPosHomeState( val screenPositionState: ScreenPositionState, val dialogState: DialogState = DialogState.Hidden, + val catalogSyncState: CatalogSyncState = CatalogSyncState.Idle, ) : Parcelable { @Parcelize sealed class ScreenPositionState : Parcelable { @@ -45,4 +46,19 @@ data class WooPosHomeState( val confirmButton: Int = R.string.woopos_exit_dialog_confirmation_confirm_button } } + + @Parcelize + sealed class CatalogSyncState : Parcelable { + @Parcelize + data object Idle : CatalogSyncState() + + @Parcelize + data object Syncing : CatalogSyncState() + + @Parcelize + data object Success : CatalogSyncState() + + @Parcelize + data class Failed(val error: String) : CatalogSyncState() + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeUIEvent.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeUIEvent.kt index 319e9f7d981b..42c4a7e8fee8 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeUIEvent.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeUIEvent.kt @@ -8,6 +8,7 @@ sealed class WooPosHomeUIEvent { data object DismissScanningSetupDialog : WooPosHomeUIEvent() data object OnPaymentCompletedViaCash : WooPosHomeUIEvent() data object ExitPosClicked : WooPosHomeUIEvent() + data object RetryCatalogSyncClicked : WooPosHomeUIEvent() data class OnBarcodeEvent( val result: BarcodeInputDetector.BarcodeResult ) : WooPosHomeUIEvent() 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 eca388187520..a475b1fcf476 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.WooPosFullSyncStatus +import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformInitialCatalogFullSync 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 performInitialFullSync: WooPosPerformInitialCatalogFullSync, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val _state = savedStateHandle.getStateFlow( @@ -54,6 +57,36 @@ class WooPosHomeViewModel @Inject constructor( viewModelScope.launch { soundHelper.preloadChaChing() } + startCatalogSyncIfNeeded() + } + + private fun startCatalogSyncIfNeeded() { + viewModelScope.launch { + performInitialFullSync().collect { syncStatus -> + when (syncStatus) { + is WooPosFullSyncStatus.NotRequired -> { + _state.value = _state.value.copy( + catalogSyncState = WooPosHomeState.CatalogSyncState.Idle + ) + } + is WooPosFullSyncStatus.InProgress -> { + _state.value = _state.value.copy( + catalogSyncState = WooPosHomeState.CatalogSyncState.Syncing + ) + } + is WooPosFullSyncStatus.Success -> { + _state.value = _state.value.copy( + catalogSyncState = WooPosHomeState.CatalogSyncState.Success + ) + } + is WooPosFullSyncStatus.Failed -> { + _state.value = _state.value.copy( + catalogSyncState = WooPosHomeState.CatalogSyncState.Failed(syncStatus.error) + ) + } + } + } + } } override fun onCleared() { @@ -88,6 +121,10 @@ class WooPosHomeViewModel @Inject constructor( } } + WooPosHomeUIEvent.RetryCatalogSyncClicked -> { + startCatalogSyncIfNeeded() + } + is WooPosHomeUIEvent.OnBarcodeEvent -> { sendEventToChildren(ParentToChildrenEvent.BarcodeEvent(event.result)) } 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 b999857109de..e9f8dc6a2f82 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 @@ -3,26 +3,42 @@ package com.woocommerce.android.ui.woopos.home.items import androidx.compose.animation.Crossfade import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State 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.navigation.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.Button +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.WooPosSearchInputState import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosSearchUIEvent 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.toAdaptivePadding +import com.woocommerce.android.ui.woopos.home.WooPosHomeState.CatalogSyncState import com.woocommerce.android.ui.woopos.home.items.WooPosItemsToolbarViewState.Tab.Coupons import com.woocommerce.android.ui.woopos.home.items.WooPosItemsToolbarViewState.Tab.HighlightLevel import com.woocommerce.android.ui.woopos.home.items.WooPosItemsToolbarViewState.Tab.Products @@ -38,7 +54,11 @@ import kotlinx.coroutines.flow.StateFlow @OptIn(ExperimentalMaterialApi::class) @Composable -fun WooPosItemsScreen(modifier: Modifier = Modifier) { +fun WooPosItemsScreen( + modifier: Modifier = Modifier, + catalogSyncState: CatalogSyncState = CatalogSyncState.Idle, + onRetryCatalogSyncClicked: () -> Unit = {} +) { val productsViewState = rememberLazyListState() val couponsListState = rememberLazyListState() val productsViewModel: WooPosItemsViewModel = hiltViewModel() @@ -47,6 +67,8 @@ fun WooPosItemsScreen(modifier: Modifier = Modifier) { itemsStateFlow = productsViewModel.viewState, productsViewState = productsViewState, couponsListState = couponsListState, + catalogSyncState = catalogSyncState, + onRetryCatalogSync = onRetryCatalogSyncClicked, onUIEvent = { productsViewModel.onUIEvent(it) }, ) } @@ -58,6 +80,8 @@ private fun WooPosItemsScreen( itemsStateFlow: StateFlow, productsViewState: LazyListState, couponsListState: LazyListState, + catalogSyncState: CatalogSyncState, + onRetryCatalogSync: () -> Unit, onUIEvent: (WooPosItemsUIEvent) -> Unit, ) { val state = itemsStateFlow.collectAsState() @@ -67,6 +91,8 @@ private fun WooPosItemsScreen( state = state, productsViewState = productsViewState, couponsListState = couponsListState, + catalogSyncState = catalogSyncState, + onRetryCatalogSync = onRetryCatalogSync, onSearchEvent = { when (it) { WooPosSearchUIEvent.Clear -> onUIEvent(WooPosItemsUIEvent.ClearSearchClicked) @@ -96,10 +122,12 @@ private fun MainItemsList( state: State, productsViewState: LazyListState, couponsListState: LazyListState, + catalogSyncState: CatalogSyncState, onSearchEvent: (WooPosSearchUIEvent) -> Unit, onTabClicked: (WooPosItemsToolbarViewState.Tab) -> Unit, onAddCouponEvent: () -> Unit, onBackClicked: () -> Unit, + onRetryCatalogSync: () -> Unit = {}, ) { Box( modifier = modifier @@ -159,6 +187,15 @@ private fun MainItemsList( } } } + + if (catalogSyncState is CatalogSyncState.Syncing || + catalogSyncState is CatalogSyncState.Failed + ) { + CatalogSyncOverlay( + catalogSyncState = catalogSyncState, + onRetryClicked = onRetryCatalogSync + ) + } } } @@ -201,6 +238,61 @@ private fun getScreenState(state: WooPosItemsToolbarViewState): ScreenState { } } +@Composable +private fun CatalogSyncOverlay( + catalogSyncState: CatalogSyncState, + onRetryClicked: () -> Unit = {} +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)), + contentAlignment = Alignment.Center + ) { + when (catalogSyncState) { + is CatalogSyncState.Syncing -> { + SyncingCatalogContent() + } + is CatalogSyncState.Failed -> { + SyncFailedContent(onRetryClicked = onRetryClicked) + } + else -> { + // Should not happen, but handle gracefully + } + } + } +} + +@Suppress("WooPosDesignSystemSpacingUsageRule", "WooPosDesignSystemTextUsageRule") +@Composable +private fun SyncingCatalogContent() { + 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 SyncFailedContent(onRetryClicked: () -> Unit) { + WooPosErrorScreen( + message = stringResource(R.string.woopos_home_sync_failed_title), + reason = stringResource(R.string.woopos_home_sync_failed_message), + primaryButton = Button( + text = stringResource(R.string.woopos_home_sync_failed_retry_button), + click = onRetryClicked + ) + ) +} + @OptIn(ExperimentalMaterialApi::class) @Composable @WooPosPreview @@ -222,6 +314,8 @@ fun WooPosItemsScreenSearchVisiblePreview(modifier: Modifier = Modifier) { itemsStateFlow = productState, productsViewState = rememberLazyListState(), couponsListState = rememberLazyListState(), + catalogSyncState = CatalogSyncState.Idle, + onRetryCatalogSync = {}, onUIEvent = {}, ) } @@ -248,6 +342,8 @@ fun WooPosItemsScreenSearchHiddenPreview(modifier: Modifier = Modifier) { itemsStateFlow = productState, productsViewState = rememberLazyListState(), couponsListState = rememberLazyListState(), + catalogSyncState = CatalogSyncState.Idle, + onRetryCatalogSync = {}, onUIEvent = {}, ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformInitialCatalogFullSync.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformInitialCatalogFullSync.kt new file mode 100644 index 000000000000..046f538d737f --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformInitialCatalogFullSync.kt @@ -0,0 +1,164 @@ +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 kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow +import org.wordpress.android.fluxc.model.LocalOrRemoteId +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogStore +import javax.inject.Inject +import kotlin.time.Duration.Companion.hours + +sealed class WooPosFullSyncStatus { + data object NotRequired : WooPosFullSyncStatus() + data object InProgress : WooPosFullSyncStatus() + data object Success : WooPosFullSyncStatus() + data class Failed(val error: String) : WooPosFullSyncStatus() +} + +class WooPosPerformInitialCatalogFullSync @Inject constructor( + private val syncRepository: WooPosLocalCatalogSyncRepository, + private val syncTimestampManager: WooPosSyncTimestampManager, + private val selectedSite: SelectedSite, + private val networkStatus: WooPosNetworkStatus, + private val wooPosLocalCatalogM1Enabled: WooPosLocalCatalogM1Enabled, + private val syncScheduler: WooPosLocalCatalogSyncScheduler, + private val localCatalogStore: WooPosLocalCatalogStore, + private val wooPosLogWrapper: WooPosLogWrapper +) { + @Suppress("LongMethod", "CyclomaticComplexMethod") + operator fun invoke(): Flow = flow { + if (!wooPosLocalCatalogM1Enabled()) { + wooPosLogWrapper.d("Full sync check skipped: Local catalog feature not enabled") + emit(WooPosFullSyncStatus.NotRequired) + return@flow + } + + val site = selectedSite.getOrNull() + if (site == null) { + wooPosLogWrapper.d("Full sync check skipped: No site selected") + emit(WooPosFullSyncStatus.NotRequired) + return@flow + } + + if (!networkStatus.isConnected()) { + wooPosLogWrapper.d("Full sync check skipped: No network connection") + emit(WooPosFullSyncStatus.Failed("No network connection")) + return@flow + } + + 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 + + val syncRequired = when { + lastFullSyncTimestamp == null -> { + wooPosLogWrapper.d("Full sync required: Never synced before") + SyncRequirement.BlockingRequired + } + catalogIsEmpty -> { + wooPosLogWrapper.d("Full sync required: Catalog is empty") + SyncRequirement.BlockingRequired + } + isFullSyncOverdue(lastFullSyncTimestamp) -> { + wooPosLogWrapper.d("Full sync required: Overdue (last sync: $lastFullSyncTimestamp)") + SyncRequirement.BackgroundRequired + } + else -> { + wooPosLogWrapper.d("Full sync not required: Recent sync found at $lastFullSyncTimestamp") + SyncRequirement.NotRequired + } + } + + when (syncRequired) { + SyncRequirement.NotRequired -> { + emit(WooPosFullSyncStatus.NotRequired) + } + SyncRequirement.BackgroundRequired -> { + wooPosLogWrapper.d("Triggering overdue full sync in background") + syncScheduler.triggerManualFullCatalogSync() + emit(WooPosFullSyncStatus.NotRequired) + } + SyncRequirement.BlockingRequired -> { + if (syncScheduler.isOneTimeWorkRunning()) { + monitorWorkerProgress() + } else { + performBlockingSync(site) + } + } + } + } + + private suspend fun FlowCollector.monitorWorkerProgress() { + wooPosLogWrapper.d("One-time full sync worker already running, monitoring progress") + emit(WooPosFullSyncStatus.InProgress) + + var workerStillRunning = true + while (workerStillRunning) { + delay(WORKER_STATUS_CHECK_INTERVAL_MS) + val completedTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() + if (completedTimestamp != null) { + wooPosLogWrapper.d("One-time worker completed successfully") + emit(WooPosFullSyncStatus.Success) + return + } + workerStillRunning = syncScheduler.isOneTimeWorkRunning() + if (!workerStillRunning) { + wooPosLogWrapper.e("One-time worker stopped without success") + emit(WooPosFullSyncStatus.Failed("Background sync worker failed")) + return + } + } + } + + private suspend fun FlowCollector.performBlockingSync(site: SiteModel) { + wooPosLogWrapper.d("Starting blocking full sync") + emit(WooPosFullSyncStatus.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(WooPosFullSyncStatus.Success) + } + is PosLocalCatalogSyncResult.Failure -> { + wooPosLogWrapper.e("Blocking full sync failed: ${syncResult.error}") + emit(WooPosFullSyncStatus.Failed(syncResult.error)) + } + } + } + + private fun isFullSyncOverdue(lastSyncTimestamp: Long): Boolean { + val currentTime = System.currentTimeMillis() + val timeSinceLastSync = currentTime - lastSyncTimestamp + val overdueThreshold = FULL_SYNC_OVERDUE_THRESHOLD.inWholeMilliseconds + return timeSinceLastSync >= overdueThreshold + } + + private enum class SyncRequirement { + NotRequired, + BackgroundRequired, + BlockingRequired + } + + companion object { + private const val WORKER_STATUS_CHECK_INTERVAL_MS = 1000L + private val FULL_SYNC_OVERDUE_THRESHOLD = 24.hours + } +} diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 6549827120cb..b8c8b551e350 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -3595,6 +3595,11 @@ + Syncing product catalog + Unable to sync + We are unable to sync your product catalog. Please check your internet connection and retry. + Retry + 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 3281ab575c23..fce628881e25 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,8 @@ 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.WooPosFullSyncStatus +import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformInitialCatalogFullSync 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 +39,7 @@ class WooPosHomeViewModelTest { private val parentToChildrenEventSender: WooPosParentToChildrenEventSender = mock() private val analyticsTracker: WooPosAnalyticsTracker = mock() private val soundHelper: WooPosSoundHelper = mock() + private val performInitialFullSync: WooPosPerformInitialCatalogFullSync = mock() @Test fun `when order created, then pass event to cart`() = @@ -341,11 +344,16 @@ class WooPosHomeViewModelTest { assertThat(viewModel.state.value.dialogState).isEqualTo(WooPosHomeState.DialogState.Hidden) } - private fun createViewModel() = WooPosHomeViewModel( - childrenToParentEventReceiver, - parentToChildrenEventSender, - analyticsTracker, - soundHelper, - SavedStateHandle() - ) + private fun createViewModel(): WooPosHomeViewModel { + whenever(performInitialFullSync.invoke()).thenReturn(flowOf(WooPosFullSyncStatus.NotRequired)) + + return WooPosHomeViewModel( + childrenToParentEventReceiver, + parentToChildrenEventSender, + analyticsTracker, + soundHelper, + performInitialFullSync, + SavedStateHandle() + ) + } } 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 000000000000..668c14c1870b --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformFullCatalogSyncUseCaseTest.kt @@ -0,0 +1,207 @@ +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 kotlinx.coroutines.flow.first +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 +import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogStore + +class WooPosPerformFullCatalogSyncUseCaseTest { + + private val syncRepository: WooPosLocalCatalogSyncRepository = mock() + private val syncTimestampManager: WooPosSyncTimestampManager = mock() + private val selectedSite: SelectedSite = mock() + private val networkStatus: WooPosNetworkStatus = mock() + private val wooPosLocalCatalogM1Enabled: WooPosLocalCatalogM1Enabled = mock() + private val syncScheduler: WooPosLocalCatalogSyncScheduler = mock() + private val localCatalogStore: WooPosLocalCatalogStore = mock() + private val wooPosLogWrapper: WooPosLogWrapper = mock() + + private val useCase = WooPosPerformInitialCatalogFullSync( + syncRepository = syncRepository, + syncTimestampManager = syncTimestampManager, + selectedSite = selectedSite, + networkStatus = networkStatus, + wooPosLocalCatalogM1Enabled = wooPosLocalCatalogM1Enabled, + syncScheduler = syncScheduler, + localCatalogStore = localCatalogStore, + wooPosLogWrapper = wooPosLogWrapper + ) + + @Test + fun `given feature flag disabled, when checkAndPerformFirstTimeSyncIfNeeded called, then returns NotRequired`() = runTest { + // GIVEN + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(false) + + // WHEN + val result = useCase().first() + + // THEN + assertThat(result).isEqualTo(WooPosFullSyncStatus.NotRequired) + } + + @Test + fun `given no site selected, when checkAndPerformFirstTimeSyncIfNeeded called, then returns NotRequired`() = runTest { + // GIVEN + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(null) + + // WHEN + val result = useCase().first() + + // THEN + assertThat(result).isEqualTo(WooPosFullSyncStatus.NotRequired) + } + + @Test + fun `given no network connection, when checkAndPerformFirstTimeSyncIfNeeded called, then returns Failed`() = runTest { + // GIVEN + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(mock()) + whenever(networkStatus.isConnected()).thenReturn(false) + + // WHEN + val result = useCase().first() + + // THEN + assertThat(result).isInstanceOf(WooPosFullSyncStatus.Failed::class.java) + assertThat((result as WooPosFullSyncStatus.Failed).error).isEqualTo("No network connection") + } + + @Test + fun `given recent sync and catalog has products, when invoke called, then returns NotRequired`() = runTest { + // GIVEN + val recentTimestamp = System.currentTimeMillis() - 1000 * 60 * 60 // 1 hour ago + val site = SiteModel().apply { id = 123 } + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(site) + whenever(networkStatus.isConnected()).thenReturn(true) + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(recentTimestamp) + whenever(localCatalogStore.getProductCount(any())).thenReturn(Result.success(10)) + + // WHEN + val result = useCase().first() + + // THEN + assertThat(result).isEqualTo(WooPosFullSyncStatus.NotRequired) + } + + @Test + fun `given no previous sync and 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(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(site) + whenever(networkStatus.isConnected()).thenReturn(true) + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(null) + whenever(localCatalogStore.getProductCount(any())).thenReturn(Result.success(0)) + whenever(syncScheduler.isOneTimeWorkRunning()).thenReturn(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(WooPosFullSyncStatus.InProgress) + assertThat(results[1]).isEqualTo(WooPosFullSyncStatus.Success) + verify(syncTimestampManager).storeFullSyncLastCompletedTimestamp(any()) + } + + @Test + fun `given no previous sync and 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(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(site) + whenever(networkStatus.isConnected()).thenReturn(true) + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(null) + whenever(localCatalogStore.getProductCount(any())).thenReturn(Result.success(0)) + whenever(syncScheduler.isOneTimeWorkRunning()).thenReturn(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(WooPosFullSyncStatus.InProgress) + assertThat(results[1]).isInstanceOf(WooPosFullSyncStatus.Failed::class.java) + assertThat((results[1] as WooPosFullSyncStatus.Failed).error).isEqualTo("Network error") + } + + @Test + fun `given catalog empty and sync succeeds, when invoke called, then blocks and returns Success`() = runTest { + // GIVEN + val site = SiteModel().apply { id = 123 } + val recentTimestamp = System.currentTimeMillis() - 1000 * 60 * 60 // 1 hour ago + val syncResult = PosLocalCatalogSyncResult.Success( + productsSynced = 10, + variationsSynced = 5, + syncDurationMs = 1000L + ) + + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(site) + whenever(networkStatus.isConnected()).thenReturn(true) + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(recentTimestamp) + whenever(localCatalogStore.getProductCount(any())).thenReturn(Result.success(0)) // Catalog is empty + whenever(syncScheduler.isOneTimeWorkRunning()).thenReturn(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(WooPosFullSyncStatus.InProgress) + assertThat(results[1]).isEqualTo(WooPosFullSyncStatus.Success) + verify(syncTimestampManager).storeFullSyncLastCompletedTimestamp(any()) + } + + @Test + fun `given sync overdue, when invoke called, then triggers background worker and returns NotRequired`() = runTest { + // GIVEN + val overdueTimestamp = System.currentTimeMillis() - 1000 * 60 * 60 * 25 // 25 hours ago + val site = SiteModel().apply { id = 123 } + whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) + whenever(selectedSite.getOrNull()).thenReturn(site) + whenever(networkStatus.isConnected()).thenReturn(true) + whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(overdueTimestamp) + whenever(localCatalogStore.getProductCount(any())).thenReturn(Result.success(10)) // Catalog has products + + // WHEN + val result = useCase().first() + + // THEN + assertThat(result).isEqualTo(WooPosFullSyncStatus.NotRequired) + verify(syncScheduler).triggerManualFullCatalogSync() + } +} From a9eac13f2659ff5431803784896f1e752872c185 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Fri, 3 Oct 2025 14:55:24 +0200 Subject: [PATCH 10/46] Remove incremental sync from POS splash --- .../android/ui/woopos/splash/WooPosSplashViewModel.kt | 5 ----- .../android/ui/woopos/splash/WooPosSplashViewModelTest.kt | 3 --- 2 files changed, 8 deletions(-) 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 8d344bff945c..6448aa1e1b52 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,8 +4,6 @@ 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.orders.WooPosOrdersInMemoryCache import com.woocommerce.android.ui.woopos.tab.WooPosCanBeLaunchedInTab import com.woocommerce.android.ui.woopos.tab.WooPosLaunchability @@ -26,7 +24,6 @@ class WooPosSplashViewModel @Inject constructor( private val analyticsTracker: WooPosAnalyticsTracker, private val posCanBeLaunchedInTab: WooPosCanBeLaunchedInTab, private val ordersCache: WooPosOrdersInMemoryCache, - performIncrementalSyncUseCase: WooPosPerformLocalCatalogIncrementalSync ) : ViewModel() { private val _state = MutableStateFlow(WooPosSplashState.Loading) val state: StateFlow = _state @@ -34,8 +31,6 @@ class WooPosSplashViewModel @Inject constructor( init { val splashScreenStartTime = System.currentTimeMillis() - performIncrementalSyncUseCase.execute(WooPosIncrementalSyncReason.ON_SPLASH_SCREEN) - viewModelScope.launch { val launchability = posCanBeLaunchedInTab() 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 92477b39ac35..15d3fe8bf8b2 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,6 @@ 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.orders.WooPosOrdersInMemoryCache import com.woocommerce.android.ui.woopos.tab.WooPosCanBeLaunchedInTab import com.woocommerce.android.ui.woopos.tab.WooPosLaunchability @@ -29,7 +28,6 @@ class WooPosSplashViewModelTest { private val analyticsTracker: WooPosAnalyticsTracker = mock() private val popularProductsProvider: WooPosPopularProductsProvider = mock() private val posCanBeLaunchedInTab: WooPosCanBeLaunchedInTab = mock() - private val performIncrementalSyncUseCase: WooPosPerformLocalCatalogIncrementalSync = mock() @Rule @JvmField @@ -160,6 +158,5 @@ class WooPosSplashViewModelTest { analyticsTracker, posCanBeLaunchedInTab, ordersCache, - performIncrementalSyncUseCase ) } From 06dd9b295b5c02701c1a2ab8e1559fa9f39ca1c7 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Fri, 3 Oct 2025 14:55:44 +0200 Subject: [PATCH 11/46] Update WooPosIncrementalSyncReason --- .../ui/woopos/localcatalog/WooPosIncrementalSyncReason.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6d0e6583f73a..7eebfe157bde 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"), } From 2a2bab8bab2e465895abf1223587e66f268b713a Mon Sep 17 00:00:00 2001 From: samiuelson Date: Fri, 3 Oct 2025 15:01:20 +0200 Subject: [PATCH 12/46] Update WooPosIncrementalSyncReason --- .../woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt | 4 ++++ .../android/ui/woopos/home/WooPosHomeViewModelTest.kt | 3 +++ 2 files changed, 7 insertions(+) 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 a475b1fcf476..aa880f849402 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 @@ -15,7 +15,9 @@ import com.woocommerce.android.ui.woopos.home.ParentToChildrenEvent.SearchEvent. 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.WooPosFullSyncStatus +import com.woocommerce.android.ui.woopos.localcatalog.WooPosIncrementalSyncReason import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformInitialCatalogFullSync +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 @@ -34,6 +36,7 @@ class WooPosHomeViewModel @Inject constructor( private val analyticsTracker: WooPosAnalyticsTracker, private val soundHelper: WooPosSoundHelper, private val performInitialFullSync: WooPosPerformInitialCatalogFullSync, + private val incrementalSync: WooPosPerformLocalCatalogIncrementalSync, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val _state = savedStateHandle.getStateFlow( @@ -68,6 +71,7 @@ class WooPosHomeViewModel @Inject constructor( _state.value = _state.value.copy( catalogSyncState = WooPosHomeState.CatalogSyncState.Idle ) + incrementalSync.execute(WooPosIncrementalSyncReason.ON_POS_HOME) } is WooPosFullSyncStatus.InProgress -> { _state.value = _state.value.copy( 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 fce628881e25..ca8ee13501ab 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 @@ -9,6 +9,7 @@ import com.woocommerce.android.ui.woopos.home.WooPosHomeUIEvent.SystemBackClicke import com.woocommerce.android.ui.woopos.home.items.WooPosItemsViewModel.ItemClickedData import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncStatus import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformInitialCatalogFullSync +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 @@ -40,6 +41,7 @@ class WooPosHomeViewModelTest { private val analyticsTracker: WooPosAnalyticsTracker = mock() private val soundHelper: WooPosSoundHelper = mock() private val performInitialFullSync: WooPosPerformInitialCatalogFullSync = mock() + private val incrementalSync: WooPosPerformLocalCatalogIncrementalSync = mock() @Test fun `when order created, then pass event to cart`() = @@ -353,6 +355,7 @@ class WooPosHomeViewModelTest { analyticsTracker, soundHelper, performInitialFullSync, + incrementalSync, SavedStateHandle() ) } From 5b38e8d45c13bf2faa0e7bd9d07f86ce8e30e5b5 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Mon, 6 Oct 2025 16:53:28 +0200 Subject: [PATCH 13/46] Extract sync state checking into WooPosFullSyncStatusChecker --- .../WooPosFullSyncStatusChecker.kt | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncStatusChecker.kt 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 000000000000..445e0977ea58 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncStatusChecker.kt @@ -0,0 +1,83 @@ +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.hours + +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 +) { + 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.d("Full sync check skipped: No site selected") + return WooPosFullSyncRequirement.NotRequired + } + + if (!networkStatus.isConnected()) { + wooPosLogWrapper.d("Full sync check skipped: No network connection") + return WooPosFullSyncRequirement.Error("No network connection") + } + + 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 + + return when { + lastFullSyncTimestamp == null -> { + wooPosLogWrapper.d("Full sync required: Never synced before") + WooPosFullSyncRequirement.BlockingRequired + } + catalogIsEmpty -> { + wooPosLogWrapper.d("Full sync required: Catalog is empty") + WooPosFullSyncRequirement.BlockingRequired + } + isFullSyncOverdue(lastFullSyncTimestamp) -> { + wooPosLogWrapper.d("Full sync overdue (last sync: $lastFullSyncTimestamp), showing banner") + WooPosFullSyncRequirement.Overdue + } + else -> { + wooPosLogWrapper.d("Full sync not required: Recent sync found at $lastFullSyncTimestamp") + 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 = 24.hours + } +} + +sealed class WooPosFullSyncRequirement { + data object NotRequired : WooPosFullSyncRequirement() + data object Overdue : WooPosFullSyncRequirement() + data object BlockingRequired : WooPosFullSyncRequirement() + data class Error(val message: String) : WooPosFullSyncRequirement() +} From 4800875015b282790e82904aef57678ce0978ae5 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Mon, 6 Oct 2025 16:55:51 +0200 Subject: [PATCH 14/46] Schedule full sync in home if needed --- .../ui/woopos/home/WooPosHomeViewModel.kt | 63 ++++--- .../WooPosPerformInitialCatalogFullSync.kt | 164 ------------------ .../WooPosPerformInstantCatalogFullSync.kt | 88 ++++++++++ .../ui/woopos/home/WooPosHomeViewModelTest.kt | 14 +- 4 files changed, 137 insertions(+), 192 deletions(-) delete mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformInitialCatalogFullSync.kt create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformInstantCatalogFullSync.kt 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 aa880f849402..10a73d4ddad8 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,9 +14,11 @@ 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.WooPosFullSyncRequirement import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncStatus +import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncStatusChecker import com.woocommerce.android.ui.woopos.localcatalog.WooPosIncrementalSyncReason -import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformInitialCatalogFullSync +import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformInstantCatalogFullSync 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 @@ -35,7 +37,8 @@ class WooPosHomeViewModel @Inject constructor( private val parentToChildrenEventSender: WooPosParentToChildrenEventSender, private val analyticsTracker: WooPosAnalyticsTracker, private val soundHelper: WooPosSoundHelper, - private val performInitialFullSync: WooPosPerformInitialCatalogFullSync, + private val syncStatusChecker: WooPosFullSyncStatusChecker, + private val performInitialFullSync: WooPosPerformInstantCatalogFullSync, private val incrementalSync: WooPosPerformLocalCatalogIncrementalSync, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -65,30 +68,42 @@ class WooPosHomeViewModel @Inject constructor( private fun startCatalogSyncIfNeeded() { viewModelScope.launch { - performInitialFullSync().collect { syncStatus -> - when (syncStatus) { - is WooPosFullSyncStatus.NotRequired -> { - _state.value = _state.value.copy( - catalogSyncState = WooPosHomeState.CatalogSyncState.Idle - ) - incrementalSync.execute(WooPosIncrementalSyncReason.ON_POS_HOME) - } - is WooPosFullSyncStatus.InProgress -> { - _state.value = _state.value.copy( - catalogSyncState = WooPosHomeState.CatalogSyncState.Syncing - ) - } - is WooPosFullSyncStatus.Success -> { - _state.value = _state.value.copy( - catalogSyncState = WooPosHomeState.CatalogSyncState.Success - ) - } - is WooPosFullSyncStatus.Failed -> { - _state.value = _state.value.copy( - catalogSyncState = WooPosHomeState.CatalogSyncState.Failed(syncStatus.error) - ) + val requirement = syncStatusChecker.checkSyncRequirement() + + when (requirement) { + is WooPosFullSyncRequirement.NotRequired, + is WooPosFullSyncRequirement.Overdue -> { + _state.value = _state.value.copy( + catalogSyncState = WooPosHomeState.CatalogSyncState.Idle + ) + incrementalSync.execute(WooPosIncrementalSyncReason.ON_POS_HOME) + } + is WooPosFullSyncRequirement.BlockingRequired -> { + performInitialFullSync().collect { syncStatus -> + when (syncStatus) { + is WooPosFullSyncStatus.InProgress -> { + _state.value = _state.value.copy( + catalogSyncState = WooPosHomeState.CatalogSyncState.Syncing + ) + } + is WooPosFullSyncStatus.Success -> { + _state.value = _state.value.copy( + catalogSyncState = WooPosHomeState.CatalogSyncState.Success + ) + } + is WooPosFullSyncStatus.Failed -> { + _state.value = _state.value.copy( + catalogSyncState = WooPosHomeState.CatalogSyncState.Failed(syncStatus.error) + ) + } + } } } + is WooPosFullSyncRequirement.Error -> { + _state.value = _state.value.copy( + catalogSyncState = WooPosHomeState.CatalogSyncState.Failed(requirement.message) + ) + } } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformInitialCatalogFullSync.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformInitialCatalogFullSync.kt deleted file mode 100644 index 046f538d737f..000000000000 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformInitialCatalogFullSync.kt +++ /dev/null @@ -1,164 +0,0 @@ -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 kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.flow -import org.wordpress.android.fluxc.model.LocalOrRemoteId -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogStore -import javax.inject.Inject -import kotlin.time.Duration.Companion.hours - -sealed class WooPosFullSyncStatus { - data object NotRequired : WooPosFullSyncStatus() - data object InProgress : WooPosFullSyncStatus() - data object Success : WooPosFullSyncStatus() - data class Failed(val error: String) : WooPosFullSyncStatus() -} - -class WooPosPerformInitialCatalogFullSync @Inject constructor( - private val syncRepository: WooPosLocalCatalogSyncRepository, - private val syncTimestampManager: WooPosSyncTimestampManager, - private val selectedSite: SelectedSite, - private val networkStatus: WooPosNetworkStatus, - private val wooPosLocalCatalogM1Enabled: WooPosLocalCatalogM1Enabled, - private val syncScheduler: WooPosLocalCatalogSyncScheduler, - private val localCatalogStore: WooPosLocalCatalogStore, - private val wooPosLogWrapper: WooPosLogWrapper -) { - @Suppress("LongMethod", "CyclomaticComplexMethod") - operator fun invoke(): Flow = flow { - if (!wooPosLocalCatalogM1Enabled()) { - wooPosLogWrapper.d("Full sync check skipped: Local catalog feature not enabled") - emit(WooPosFullSyncStatus.NotRequired) - return@flow - } - - val site = selectedSite.getOrNull() - if (site == null) { - wooPosLogWrapper.d("Full sync check skipped: No site selected") - emit(WooPosFullSyncStatus.NotRequired) - return@flow - } - - if (!networkStatus.isConnected()) { - wooPosLogWrapper.d("Full sync check skipped: No network connection") - emit(WooPosFullSyncStatus.Failed("No network connection")) - return@flow - } - - 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 - - val syncRequired = when { - lastFullSyncTimestamp == null -> { - wooPosLogWrapper.d("Full sync required: Never synced before") - SyncRequirement.BlockingRequired - } - catalogIsEmpty -> { - wooPosLogWrapper.d("Full sync required: Catalog is empty") - SyncRequirement.BlockingRequired - } - isFullSyncOverdue(lastFullSyncTimestamp) -> { - wooPosLogWrapper.d("Full sync required: Overdue (last sync: $lastFullSyncTimestamp)") - SyncRequirement.BackgroundRequired - } - else -> { - wooPosLogWrapper.d("Full sync not required: Recent sync found at $lastFullSyncTimestamp") - SyncRequirement.NotRequired - } - } - - when (syncRequired) { - SyncRequirement.NotRequired -> { - emit(WooPosFullSyncStatus.NotRequired) - } - SyncRequirement.BackgroundRequired -> { - wooPosLogWrapper.d("Triggering overdue full sync in background") - syncScheduler.triggerManualFullCatalogSync() - emit(WooPosFullSyncStatus.NotRequired) - } - SyncRequirement.BlockingRequired -> { - if (syncScheduler.isOneTimeWorkRunning()) { - monitorWorkerProgress() - } else { - performBlockingSync(site) - } - } - } - } - - private suspend fun FlowCollector.monitorWorkerProgress() { - wooPosLogWrapper.d("One-time full sync worker already running, monitoring progress") - emit(WooPosFullSyncStatus.InProgress) - - var workerStillRunning = true - while (workerStillRunning) { - delay(WORKER_STATUS_CHECK_INTERVAL_MS) - val completedTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() - if (completedTimestamp != null) { - wooPosLogWrapper.d("One-time worker completed successfully") - emit(WooPosFullSyncStatus.Success) - return - } - workerStillRunning = syncScheduler.isOneTimeWorkRunning() - if (!workerStillRunning) { - wooPosLogWrapper.e("One-time worker stopped without success") - emit(WooPosFullSyncStatus.Failed("Background sync worker failed")) - return - } - } - } - - private suspend fun FlowCollector.performBlockingSync(site: SiteModel) { - wooPosLogWrapper.d("Starting blocking full sync") - emit(WooPosFullSyncStatus.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(WooPosFullSyncStatus.Success) - } - is PosLocalCatalogSyncResult.Failure -> { - wooPosLogWrapper.e("Blocking full sync failed: ${syncResult.error}") - emit(WooPosFullSyncStatus.Failed(syncResult.error)) - } - } - } - - private fun isFullSyncOverdue(lastSyncTimestamp: Long): Boolean { - val currentTime = System.currentTimeMillis() - val timeSinceLastSync = currentTime - lastSyncTimestamp - val overdueThreshold = FULL_SYNC_OVERDUE_THRESHOLD.inWholeMilliseconds - return timeSinceLastSync >= overdueThreshold - } - - private enum class SyncRequirement { - NotRequired, - BackgroundRequired, - BlockingRequired - } - - companion object { - private const val WORKER_STATUS_CHECK_INTERVAL_MS = 1000L - private val FULL_SYNC_OVERDUE_THRESHOLD = 24.hours - } -} 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 000000000000..6af54c36f8c4 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformInstantCatalogFullSync.kt @@ -0,0 +1,88 @@ +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.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +sealed class WooPosFullSyncStatus { + data object InProgress : WooPosFullSyncStatus() + data object Success : WooPosFullSyncStatus() + data class Failed(val error: String) : WooPosFullSyncStatus() +} + +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 { + if (syncScheduler.isOneTimeWorkRunning()) { + monitorWorkerProgress() + } else { + performBlockingSync() + } + } + + private suspend fun FlowCollector.monitorWorkerProgress() { + wooPosLogWrapper.d("One-time full sync worker already running, monitoring progress") + emit(WooPosFullSyncStatus.InProgress) + + var workerStillRunning = true + while (workerStillRunning) { + delay(WORKER_STATUS_CHECK_INTERVAL_MS) + val completedTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() + if (completedTimestamp != null) { + wooPosLogWrapper.d("One-time worker completed successfully") + emit(WooPosFullSyncStatus.Success) + return + } + workerStillRunning = syncScheduler.isOneTimeWorkRunning() + if (!workerStillRunning) { + wooPosLogWrapper.e("One-time worker stopped without success") + emit(WooPosFullSyncStatus.Failed("Background sync worker failed")) + return + } + } + } + + private suspend fun FlowCollector.performBlockingSync() { + val site = selectedSite.getOrNull() + if (site == null) { + wooPosLogWrapper.e("Cannot perform blocking sync: No site selected") + emit(WooPosFullSyncStatus.Failed("No site selected")) + return + } + + wooPosLogWrapper.d("Starting blocking full sync") + emit(WooPosFullSyncStatus.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(WooPosFullSyncStatus.Success) + } + is PosLocalCatalogSyncResult.Failure -> { + wooPosLogWrapper.e("Blocking full sync failed: ${syncResult.error}") + emit(WooPosFullSyncStatus.Failed(syncResult.error)) + } + } + } + + companion object { + private const val WORKER_STATUS_CHECK_INTERVAL_MS = 1000L + } +} 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 ca8ee13501ab..f49543e73307 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,8 +7,9 @@ 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.WooPosFullSyncStatus -import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformInitialCatalogFullSync +import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncRequirement +import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncStatusChecker +import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformInstantCatalogFullSync 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 @@ -17,6 +18,7 @@ import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.Rule @@ -40,8 +42,10 @@ class WooPosHomeViewModelTest { private val parentToChildrenEventSender: WooPosParentToChildrenEventSender = mock() private val analyticsTracker: WooPosAnalyticsTracker = mock() private val soundHelper: WooPosSoundHelper = mock() - private val performInitialFullSync: WooPosPerformInitialCatalogFullSync = mock() + private val syncStatusChecker: WooPosFullSyncStatusChecker = mock() + private val performInitialFullSync: WooPosPerformInstantCatalogFullSync = mock() private val incrementalSync: WooPosPerformLocalCatalogIncrementalSync = mock() + private val syncScheduler: com.woocommerce.android.ui.woopos.localcatalog.WooPosLocalCatalogSyncScheduler = mock() @Test fun `when order created, then pass event to cart`() = @@ -347,15 +351,17 @@ class WooPosHomeViewModelTest { } private fun createViewModel(): WooPosHomeViewModel { - whenever(performInitialFullSync.invoke()).thenReturn(flowOf(WooPosFullSyncStatus.NotRequired)) + whenever(runBlocking { syncStatusChecker.checkSyncRequirement() }).thenReturn(WooPosFullSyncRequirement.NotRequired) return WooPosHomeViewModel( childrenToParentEventReceiver, parentToChildrenEventSender, analyticsTracker, soundHelper, + syncStatusChecker, performInitialFullSync, incrementalSync, + syncScheduler, SavedStateHandle() ) } From e6bb3a9659b3d5f37d67ad464d5440d15c217195 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Mon, 6 Oct 2025 17:15:42 +0200 Subject: [PATCH 15/46] Fix tests --- .../ui/woopos/home/WooPosHomeViewModelTest.kt | 6 +- ...WooPosPerformFullCatalogSyncUseCaseTest.kt | 126 +----------------- 2 files changed, 10 insertions(+), 122 deletions(-) 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 f49543e73307..0f1bbfa156b6 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 @@ -45,7 +45,6 @@ class WooPosHomeViewModelTest { private val syncStatusChecker: WooPosFullSyncStatusChecker = mock() private val performInitialFullSync: WooPosPerformInstantCatalogFullSync = mock() private val incrementalSync: WooPosPerformLocalCatalogIncrementalSync = mock() - private val syncScheduler: com.woocommerce.android.ui.woopos.localcatalog.WooPosLocalCatalogSyncScheduler = mock() @Test fun `when order created, then pass event to cart`() = @@ -351,7 +350,9 @@ class WooPosHomeViewModelTest { } private fun createViewModel(): WooPosHomeViewModel { - whenever(runBlocking { syncStatusChecker.checkSyncRequirement() }).thenReturn(WooPosFullSyncRequirement.NotRequired) + whenever(runBlocking { syncStatusChecker.checkSyncRequirement() }).thenReturn( + WooPosFullSyncRequirement.NotRequired + ) return WooPosHomeViewModel( childrenToParentEventReceiver, @@ -361,7 +362,6 @@ class WooPosHomeViewModelTest { syncStatusChecker, performInitialFullSync, incrementalSync, - syncScheduler, SavedStateHandle() ) } 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 index 668c14c1870b..a81c427b96cf 100644 --- 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 @@ -2,8 +2,6 @@ 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 kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -14,90 +12,39 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogStore class WooPosPerformFullCatalogSyncUseCaseTest { private val syncRepository: WooPosLocalCatalogSyncRepository = mock() private val syncTimestampManager: WooPosSyncTimestampManager = mock() private val selectedSite: SelectedSite = mock() - private val networkStatus: WooPosNetworkStatus = mock() - private val wooPosLocalCatalogM1Enabled: WooPosLocalCatalogM1Enabled = mock() private val syncScheduler: WooPosLocalCatalogSyncScheduler = mock() - private val localCatalogStore: WooPosLocalCatalogStore = mock() private val wooPosLogWrapper: WooPosLogWrapper = mock() - private val useCase = WooPosPerformInitialCatalogFullSync( + private val useCase = WooPosPerformInstantCatalogFullSync( syncRepository = syncRepository, syncTimestampManager = syncTimestampManager, - selectedSite = selectedSite, - networkStatus = networkStatus, - wooPosLocalCatalogM1Enabled = wooPosLocalCatalogM1Enabled, syncScheduler = syncScheduler, - localCatalogStore = localCatalogStore, + selectedSite = selectedSite, wooPosLogWrapper = wooPosLogWrapper ) @Test - fun `given feature flag disabled, when checkAndPerformFirstTimeSyncIfNeeded called, then returns NotRequired`() = runTest { - // GIVEN - whenever(wooPosLocalCatalogM1Enabled()).thenReturn(false) - - // WHEN - val result = useCase().first() - - // THEN - assertThat(result).isEqualTo(WooPosFullSyncStatus.NotRequired) - } - - @Test - fun `given no site selected, when checkAndPerformFirstTimeSyncIfNeeded called, then returns NotRequired`() = runTest { + fun `given no site selected, when invoke called, then returns Failed`() = runTest { // GIVEN - whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) whenever(selectedSite.getOrNull()).thenReturn(null) - - // WHEN - val result = useCase().first() - - // THEN - assertThat(result).isEqualTo(WooPosFullSyncStatus.NotRequired) - } - - @Test - fun `given no network connection, when checkAndPerformFirstTimeSyncIfNeeded called, then returns Failed`() = runTest { - // GIVEN - whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) - whenever(selectedSite.getOrNull()).thenReturn(mock()) - whenever(networkStatus.isConnected()).thenReturn(false) + whenever(syncScheduler.isOneTimeWorkRunning()).thenReturn(false) // WHEN val result = useCase().first() // THEN assertThat(result).isInstanceOf(WooPosFullSyncStatus.Failed::class.java) - assertThat((result as WooPosFullSyncStatus.Failed).error).isEqualTo("No network connection") + assertThat((result as WooPosFullSyncStatus.Failed).error).isEqualTo("No site selected") } @Test - fun `given recent sync and catalog has products, when invoke called, then returns NotRequired`() = runTest { - // GIVEN - val recentTimestamp = System.currentTimeMillis() - 1000 * 60 * 60 // 1 hour ago - val site = SiteModel().apply { id = 123 } - whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) - whenever(selectedSite.getOrNull()).thenReturn(site) - whenever(networkStatus.isConnected()).thenReturn(true) - whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(recentTimestamp) - whenever(localCatalogStore.getProductCount(any())).thenReturn(Result.success(10)) - - // WHEN - val result = useCase().first() - - // THEN - assertThat(result).isEqualTo(WooPosFullSyncStatus.NotRequired) - } - - @Test - fun `given no previous sync and sync succeeds, when invoke called, then blocks and returns Success`() = runTest { + fun `given sync succeeds, when invoke called, then blocks and returns Success`() = runTest { // GIVEN val site = SiteModel().apply { id = 123 } val syncResult = PosLocalCatalogSyncResult.Success( @@ -106,11 +53,7 @@ class WooPosPerformFullCatalogSyncUseCaseTest { syncDurationMs = 1000L ) - whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) whenever(selectedSite.getOrNull()).thenReturn(site) - whenever(networkStatus.isConnected()).thenReturn(true) - whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(null) - whenever(localCatalogStore.getProductCount(any())).thenReturn(Result.success(0)) whenever(syncScheduler.isOneTimeWorkRunning()).thenReturn(false) whenever(syncRepository.syncLocalCatalogFull(site)).thenReturn(syncResult) @@ -128,16 +71,12 @@ class WooPosPerformFullCatalogSyncUseCaseTest { } @Test - fun `given no previous sync and sync fails, when invoke called, then blocks and returns Failed`() = runTest { + 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(wooPosLocalCatalogM1Enabled()).thenReturn(true) whenever(selectedSite.getOrNull()).thenReturn(site) - whenever(networkStatus.isConnected()).thenReturn(true) - whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(null) - whenever(localCatalogStore.getProductCount(any())).thenReturn(Result.success(0)) whenever(syncScheduler.isOneTimeWorkRunning()).thenReturn(false) whenever(syncRepository.syncLocalCatalogFull(site)).thenReturn(syncResult) @@ -153,55 +92,4 @@ class WooPosPerformFullCatalogSyncUseCaseTest { assertThat(results[1]).isInstanceOf(WooPosFullSyncStatus.Failed::class.java) assertThat((results[1] as WooPosFullSyncStatus.Failed).error).isEqualTo("Network error") } - - @Test - fun `given catalog empty and sync succeeds, when invoke called, then blocks and returns Success`() = runTest { - // GIVEN - val site = SiteModel().apply { id = 123 } - val recentTimestamp = System.currentTimeMillis() - 1000 * 60 * 60 // 1 hour ago - val syncResult = PosLocalCatalogSyncResult.Success( - productsSynced = 10, - variationsSynced = 5, - syncDurationMs = 1000L - ) - - whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) - whenever(selectedSite.getOrNull()).thenReturn(site) - whenever(networkStatus.isConnected()).thenReturn(true) - whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(recentTimestamp) - whenever(localCatalogStore.getProductCount(any())).thenReturn(Result.success(0)) // Catalog is empty - whenever(syncScheduler.isOneTimeWorkRunning()).thenReturn(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(WooPosFullSyncStatus.InProgress) - assertThat(results[1]).isEqualTo(WooPosFullSyncStatus.Success) - verify(syncTimestampManager).storeFullSyncLastCompletedTimestamp(any()) - } - - @Test - fun `given sync overdue, when invoke called, then triggers background worker and returns NotRequired`() = runTest { - // GIVEN - val overdueTimestamp = System.currentTimeMillis() - 1000 * 60 * 60 * 25 // 25 hours ago - val site = SiteModel().apply { id = 123 } - whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) - whenever(selectedSite.getOrNull()).thenReturn(site) - whenever(networkStatus.isConnected()).thenReturn(true) - whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(overdueTimestamp) - whenever(localCatalogStore.getProductCount(any())).thenReturn(Result.success(10)) // Catalog has products - - // WHEN - val result = useCase().first() - - // THEN - assertThat(result).isEqualTo(WooPosFullSyncStatus.NotRequired) - verify(syncScheduler).triggerManualFullCatalogSync() - } } From 3f7fed286682eac9eda808732bad935219739447 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Mon, 6 Oct 2025 17:21:14 +0200 Subject: [PATCH 16/46] Satisfy detekt's complaints --- .../ui/woopos/localcatalog/WooPosFullSyncStatusChecker.kt | 1 + 1 file changed, 1 insertion(+) 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 index 445e0977ea58..4eb5f9dfc4e7 100644 --- 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 @@ -18,6 +18,7 @@ class WooPosFullSyncStatusChecker @Inject constructor( 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") From 2a54355952979e5d40e152e97acdc82f1bf34e1f Mon Sep 17 00:00:00 2001 From: samiuelson Date: Mon, 6 Oct 2025 17:21:52 +0200 Subject: [PATCH 17/46] Show warning baner in case the full sync is overdue --- .../ui/woopos/home/items/WooPosItemsScreen.kt | 17 ++++ .../woopos/home/items/WooPosItemsViewModel.kt | 23 ++++++ .../home/items/WooPosRefreshCatalogBanner.kt | 80 +++++++++++++++++++ WooCommerce/src/main/res/values/strings.xml | 1 + .../home/items/WooPosItemsViewModelTest.kt | 3 + 5 files changed, 124 insertions(+) create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt 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 8327908e08a0..6f51e5fdb3ee 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 @@ -65,6 +65,7 @@ fun WooPosItemsScreen( WooPosItemsScreen( modifier = modifier, itemsStateFlow = productsViewModel.viewState, + catalogSyncBannerStateFlow = productsViewModel.catalogSyncBannerState, productsViewState = productsViewState, couponsListState = couponsListState, catalogSyncState = catalogSyncState, @@ -78,6 +79,7 @@ fun WooPosItemsScreen( private fun WooPosItemsScreen( modifier: Modifier = Modifier, itemsStateFlow: StateFlow, + catalogSyncBannerStateFlow: StateFlow, productsViewState: LazyListState, couponsListState: LazyListState, catalogSyncState: CatalogSyncState, @@ -85,10 +87,12 @@ private fun WooPosItemsScreen( onUIEvent: (WooPosItemsUIEvent) -> Unit, ) { val state = itemsStateFlow.collectAsState() + val bannerState = catalogSyncBannerStateFlow.collectAsState() MainItemsList( modifier = modifier, state = state, + bannerState = bannerState, productsViewState = productsViewState, couponsListState = couponsListState, catalogSyncState = catalogSyncState, @@ -120,6 +124,7 @@ private fun WooPosItemsScreen( private fun MainItemsList( modifier: Modifier, state: State, + bannerState: State, productsViewState: LazyListState, couponsListState: LazyListState, catalogSyncState: CatalogSyncState, @@ -149,6 +154,14 @@ private fun MainItemsList( onAddCouponEvent = onAddCouponEvent, ) + WooPosRefreshCatalogBanner( + bannerState = bannerState.value, + modifier = Modifier.padding( + horizontal = WooPosSpacing.Medium.value.toAdaptivePadding(), + vertical = WooPosSpacing.Small.value.toAdaptivePadding(), + ) + ) + val currentState = state.value Crossfade( @@ -308,10 +321,12 @@ fun WooPosItemsScreenSearchVisiblePreview(modifier: Modifier = Modifier) { tabs = tabs() ) ) + val bannerState = MutableStateFlow(WooPosItemsViewModel.CatalogSyncBannerState.OverdueWarning) WooPosTheme { WooPosItemsScreen( modifier = modifier, itemsStateFlow = productState, + catalogSyncBannerStateFlow = bannerState, productsViewState = rememberLazyListState(), couponsListState = rememberLazyListState(), catalogSyncState = CatalogSyncState.Idle, @@ -336,10 +351,12 @@ fun WooPosItemsScreenSearchHiddenPreview(modifier: Modifier = Modifier) { tabs = tabs() ) ) + val bannerState = MutableStateFlow(WooPosItemsViewModel.CatalogSyncBannerState.OverdueWarning) WooPosTheme { WooPosItemsScreen( modifier = modifier, itemsStateFlow = productState, + catalogSyncBannerStateFlow = bannerState, productsViewState = rememberLazyListState(), couponsListState = rememberLazyListState(), catalogSyncState = CatalogSyncState.Idle, 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 33b11d875f28..bc0ee7cf8dc8 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,9 @@ class WooPosItemsViewModel @Inject constructor( initialValue = _viewState.value, ) + private val _catalogSyncBannerState = MutableStateFlow(CatalogSyncBannerState.Hidden) + val catalogSyncBannerState: StateFlow = _catalogSyncBannerState + init { listenUpEvents() searchHelper.initialize( @@ -55,6 +61,18 @@ class WooPosItemsViewModel @Inject constructor( viewModelScope.launch { preferencesRepository.setWasOpenedOnce(true) } + + checkSyncStatusAndUpdateBanner() + } + + private fun checkSyncStatusAndUpdateBanner() { + viewModelScope.launch { + val requirement = syncStatusChecker.checkSyncRequirement() + _catalogSyncBannerState.value = when (requirement) { + is WooPosFullSyncRequirement.Overdue -> CatalogSyncBannerState.OverdueWarning + else -> CatalogSyncBannerState.Hidden + } + } } fun onUIEvent(event: WooPosItemsUIEvent) { @@ -241,4 +259,9 @@ class WooPosItemsViewModel @Inject constructor( @Parcelize data class Coupon(override val id: Long, val couponCode: String) : ItemClickedData(id), Parcelable } + + sealed class CatalogSyncBannerState { + data object Hidden : CatalogSyncBannerState() + data object OverdueWarning : CatalogSyncBannerState() + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt new file mode 100644 index 000000000000..59f9ffcbdf99 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt @@ -0,0 +1,80 @@ +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.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Icon +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.stringResource +import com.woocommerce.android.R +import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview +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.WooPosSpacing +import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTheme +import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTypography + +@Composable +fun WooPosRefreshCatalogBanner( + bannerState: WooPosItemsViewModel.CatalogSyncBannerState, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = bannerState is WooPosItemsViewModel.CatalogSyncBannerState.OverdueWarning, + enter = fadeIn( + animationSpec = tween(durationMillis = 180) + ) + scaleIn( + animationSpec = tween(durationMillis = 180) + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(WooPosCornerRadius.Medium.value) + ) + .padding( + vertical = WooPosSpacing.Small.value, + horizontal = WooPosSpacing.Medium.value + ), + ) { + Icon( + imageVector = Icons.Outlined.Info, + tint = MaterialTheme.colorScheme.onErrorContainer, + contentDescription = null + ) + Spacer(Modifier.size(WooPosSpacing.Small.value)) + WooPosText( + text = stringResource(R.string.woopos_refresh_catalog_banner_message), + style = WooPosTypography.BodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +@WooPosPreview +fun WooPosRefreshCatalogBannerPreview() { + WooPosTheme { + WooPosRefreshCatalogBanner( + bannerState = WooPosItemsViewModel.CatalogSyncBannerState.OverdueWarning + ) + } +} diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index a6d44f0440ac..4e035573790d 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -3599,6 +3599,7 @@ Unable to sync We are unable to sync your product catalog. Please check your internet connection and retry. Retry + 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. Reader connected Connect your reader 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 72aa0356291a..47868930d46e 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, ) } } From 24ea7720ef657af5da333f88991493fa93e54c48 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Mon, 6 Oct 2025 17:51:32 +0200 Subject: [PATCH 18/46] Do not do full sync on app start --- .../com/woocommerce/android/AppInitializer.kt | 5 - .../WooPosFullSyncCheckUseCase.kt | 65 -------- .../WooPosFullSyncCheckUseCaseTest.kt | 157 ------------------ 3 files changed, 227 deletions(-) delete mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCase.kt delete mode 100644 WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt index 31796488de63..6e6a797b0f8c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/AppInitializer.kt @@ -44,7 +44,6 @@ import com.woocommerce.android.ui.jitm.JitmStoreInMemoryCache import com.woocommerce.android.ui.login.AccountRepository import com.woocommerce.android.ui.main.MainActivity import com.woocommerce.android.ui.payments.cardreader.onboarding.CardReaderOnboardingChecker -import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncCheckUseCase import com.woocommerce.android.ui.woopos.localcatalog.WooPosLocalCatalogSyncScheduler import com.woocommerce.android.util.AppThemeUtils import com.woocommerce.android.util.ApplicationEdgeToEdgeEnabler @@ -166,8 +165,6 @@ class AppInitializer @Inject constructor() : ApplicationLifecycleListener { @Inject lateinit var posLocalCatalogScheduler: WooPosLocalCatalogSyncScheduler - @Inject lateinit var posCatalogSyncCheck: WooPosFullSyncCheckUseCase - private var connectionReceiverRegistered = false private lateinit var application: Application @@ -272,8 +269,6 @@ class AppInitializer @Inject constructor() : ApplicationLifecycleListener { appCoroutineScope.launch { registerDevice(IF_NEEDED) - - posCatalogSyncCheck.checkAndTriggerSyncIfNeeded() } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCase.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCase.kt deleted file mode 100644 index f6d1061811fa..000000000000 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCase.kt +++ /dev/null @@ -1,65 +0,0 @@ -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 java.util.concurrent.TimeUnit -import javax.inject.Inject - -class WooPosFullSyncCheckUseCase @Inject constructor( - private val syncTimestampManager: WooPosSyncTimestampManager, - private val syncScheduler: WooPosLocalCatalogSyncScheduler, - private val selectedSite: SelectedSite, - private val networkStatus: WooPosNetworkStatus, - private val wooPosLocalCatalogM1Enabled: WooPosLocalCatalogM1Enabled, - private val wooPosLogWrapper: WooPosLogWrapper -) { - companion object { - private const val FULL_SYNC_INTERVAL_HOURS = 24L - private val FULL_SYNC_INTERVAL_MILLIS = TimeUnit.HOURS.toMillis(FULL_SYNC_INTERVAL_HOURS) - } - - @Suppress("ReturnCount") - suspend fun checkAndTriggerSyncIfNeeded() { - if (!wooPosLocalCatalogM1Enabled()) { - wooPosLogWrapper.d("Local catalog feature not enabled") - return - } - - if (selectedSite.getOrNull() == null) { - wooPosLogWrapper.d("No site selected") - return - } - - if (!networkStatus.isConnected()) { - wooPosLogWrapper.d("No network connection") - return - } - - if (syncScheduler.isPeriodicWorkRunning() || syncScheduler.isOneTimeWorkRunning()) { - wooPosLogWrapper.d("Sync already running") - return - } - - val lastFullSyncTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() - val currentTime = System.currentTimeMillis() - - if (lastFullSyncTimestamp == null) { - wooPosLogWrapper.i("No previous full sync found - triggering immediate sync") - syncScheduler.triggerManualFullCatalogSync() - return - } - - val timeSinceLastSync = currentTime - lastFullSyncTimestamp - if (timeSinceLastSync > FULL_SYNC_INTERVAL_MILLIS) { - val hoursSinceSync = TimeUnit.MILLISECONDS.toHours(timeSinceLastSync) - wooPosLogWrapper.i("Last full sync was $hoursSinceSync hours ago - triggering immediate sync") - syncScheduler.triggerManualFullCatalogSync() - } else { - val hoursUntilNext = TimeUnit.MILLISECONDS.toHours(FULL_SYNC_INTERVAL_MILLIS - timeSinceLastSync) - wooPosLogWrapper.d("Full sync is up to date - next sync needed in $hoursUntilNext hours") - } - } -} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt deleted file mode 100644 index a64b644a2d03..000000000000 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncCheckUseCaseTest.kt +++ /dev/null @@ -1,157 +0,0 @@ -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.junit.Rule -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.wordpress.android.fluxc.model.SiteModel -import java.util.concurrent.TimeUnit -import kotlin.test.Test - -@ExperimentalCoroutinesApi -class WooPosFullSyncCheckUseCaseTest { - - @Rule - @JvmField - val coroutinesTestRule = WooPosCoroutineTestRule() - - companion object { - private val TWENTY_FIVE_HOURS_MILLIS = TimeUnit.HOURS.toMillis(25) - private val TWO_HOURS_MILLIS = TimeUnit.HOURS.toMillis(2) - } - - private val syncTimestampManager: WooPosSyncTimestampManager = mock() - private val syncScheduler: WooPosLocalCatalogSyncScheduler = mock() - private val selectedSite: SelectedSite = mock() - private val networkStatus: WooPosNetworkStatus = mock() - private val wooPosLocalCatalogM1Enabled: WooPosLocalCatalogM1Enabled = mock() - private val wooPosLogWrapper: WooPosLogWrapper = mock() - - private val useCase = WooPosFullSyncCheckUseCase( - syncTimestampManager = syncTimestampManager, - syncScheduler = syncScheduler, - selectedSite = selectedSite, - networkStatus = networkStatus, - wooPosLocalCatalogM1Enabled = wooPosLocalCatalogM1Enabled, - wooPosLogWrapper = wooPosLogWrapper - ) - - @Test - fun `given feature flag disabled, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = - runTest { - // GIVEN - whenever(wooPosLocalCatalogM1Enabled()).thenReturn(false) - - // WHEN - useCase.checkAndTriggerSyncIfNeeded() - - // THEN - verify(syncScheduler, never()).triggerManualFullCatalogSync() - } - - @Test - fun `given no site selected, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = - runTest { - // GIVEN - whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) - whenever(selectedSite.getOrNull()).thenReturn(null) - - // WHEN - useCase.checkAndTriggerSyncIfNeeded() - - // THEN - verify(syncScheduler, never()).triggerManualFullCatalogSync() - } - - @Test - fun `given no network connection, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = - runTest { - // GIVEN - whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) - whenever(selectedSite.getOrNull()).thenReturn(mock()) - whenever(networkStatus.isConnected()).thenReturn(false) - - // WHEN - useCase.checkAndTriggerSyncIfNeeded() - - // THEN - verify(syncScheduler, never()).triggerManualFullCatalogSync() - } - - @Test - fun `given sync is running, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = - runTest { - // GIVEN - givenAllPrerequisitesMet() - whenever(syncScheduler.isPeriodicWorkRunning()).thenReturn(true) - - // WHEN - useCase.checkAndTriggerSyncIfNeeded() - - // THEN - verify(syncScheduler, never()).triggerManualFullCatalogSync() - } - - @Test - fun `given no previous sync, when checkAndTriggerSyncIfNeeded called, then triggers sync`() = - runTest { - // GIVEN - givenAllPrerequisitesMet() - whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(null) - - // WHEN - useCase.checkAndTriggerSyncIfNeeded() - - // THEN - verify(syncScheduler).triggerManualFullCatalogSync() - } - - @Test - fun `given sync older than 24 hours, when checkAndTriggerSyncIfNeeded called, then triggers sync`() = - runTest { - // GIVEN - val currentTime = System.currentTimeMillis() - val twentyFiveHoursAgo = currentTime - TWENTY_FIVE_HOURS_MILLIS - givenAllPrerequisitesMet() - whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(twentyFiveHoursAgo) - - // WHEN - useCase.checkAndTriggerSyncIfNeeded() - - // THEN - verify(syncScheduler).triggerManualFullCatalogSync() - } - - @Test - fun `given sync newer than 24 hours, when checkAndTriggerSyncIfNeeded called, then does not trigger sync`() = - runTest { - // GIVEN - val currentTime = System.currentTimeMillis() - val twoHoursAgo = currentTime - TWO_HOURS_MILLIS - givenAllPrerequisitesMet() - whenever(syncTimestampManager.getFullSyncLastCompletedTimestamp()).thenReturn(twoHoursAgo) - - // WHEN - useCase.checkAndTriggerSyncIfNeeded() - - // THEN - verify(syncScheduler, never()).triggerManualFullCatalogSync() - } - - private fun givenAllPrerequisitesMet() { - whenever(wooPosLocalCatalogM1Enabled()).thenReturn(true) - whenever(selectedSite.getOrNull()).thenReturn(mock()) - whenever(networkStatus.isConnected()).thenReturn(true) - whenever(syncScheduler.isPeriodicWorkRunning()).thenReturn(false) - whenever(syncScheduler.isOneTimeWorkRunning()).thenReturn(false) - } -} From f0ad8bf8904f2aab82c6d6c6084214c1c77ba21c Mon Sep 17 00:00:00 2001 From: samiuelson Date: Mon, 6 Oct 2025 17:51:54 +0200 Subject: [PATCH 19/46] Rename class --- .../ui/woopos/home/WooPosHomeViewModel.kt | 8 ++--- .../WooPosPerformInstantCatalogFullSync.kt | 32 +++++++++---------- ...WooPosPerformFullCatalogSyncUseCaseTest.kt | 18 +++++------ 3 files changed, 29 insertions(+), 29 deletions(-) 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 10a73d4ddad8..4decbc043827 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 @@ -15,7 +15,7 @@ import com.woocommerce.android.ui.woopos.home.ParentToChildrenEvent.SearchEvent. 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.WooPosFullSyncRequirement -import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncStatus +import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncState import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncStatusChecker import com.woocommerce.android.ui.woopos.localcatalog.WooPosIncrementalSyncReason import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformInstantCatalogFullSync @@ -81,17 +81,17 @@ class WooPosHomeViewModel @Inject constructor( is WooPosFullSyncRequirement.BlockingRequired -> { performInitialFullSync().collect { syncStatus -> when (syncStatus) { - is WooPosFullSyncStatus.InProgress -> { + is WooPosFullSyncState.InProgress -> { _state.value = _state.value.copy( catalogSyncState = WooPosHomeState.CatalogSyncState.Syncing ) } - is WooPosFullSyncStatus.Success -> { + is WooPosFullSyncState.Success -> { _state.value = _state.value.copy( catalogSyncState = WooPosHomeState.CatalogSyncState.Success ) } - is WooPosFullSyncStatus.Failed -> { + is WooPosFullSyncState.Failed -> { _state.value = _state.value.copy( catalogSyncState = WooPosHomeState.CatalogSyncState.Failed(syncStatus.error) ) 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 index 6af54c36f8c4..2db2039b51b6 100644 --- 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 @@ -9,12 +9,6 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.flow import javax.inject.Inject -sealed class WooPosFullSyncStatus { - data object InProgress : WooPosFullSyncStatus() - data object Success : WooPosFullSyncStatus() - data class Failed(val error: String) : WooPosFullSyncStatus() -} - class WooPosPerformInstantCatalogFullSync @Inject constructor( private val syncRepository: WooPosLocalCatalogSyncRepository, private val syncTimestampManager: WooPosSyncTimestampManager, @@ -22,7 +16,7 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( private val selectedSite: SelectedSite, private val wooPosLogWrapper: WooPosLogWrapper ) { - operator fun invoke(): Flow = flow { + operator fun invoke(): Flow = flow { if (syncScheduler.isOneTimeWorkRunning()) { monitorWorkerProgress() } else { @@ -30,9 +24,9 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( } } - private suspend fun FlowCollector.monitorWorkerProgress() { + private suspend fun FlowCollector.monitorWorkerProgress() { wooPosLogWrapper.d("One-time full sync worker already running, monitoring progress") - emit(WooPosFullSyncStatus.InProgress) + emit(WooPosFullSyncState.InProgress) var workerStillRunning = true while (workerStillRunning) { @@ -40,28 +34,28 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( val completedTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() if (completedTimestamp != null) { wooPosLogWrapper.d("One-time worker completed successfully") - emit(WooPosFullSyncStatus.Success) + emit(WooPosFullSyncState.Success) return } workerStillRunning = syncScheduler.isOneTimeWorkRunning() if (!workerStillRunning) { wooPosLogWrapper.e("One-time worker stopped without success") - emit(WooPosFullSyncStatus.Failed("Background sync worker failed")) + emit(WooPosFullSyncState.Failed("Background sync worker failed")) return } } } - private suspend fun FlowCollector.performBlockingSync() { + private suspend fun FlowCollector.performBlockingSync() { val site = selectedSite.getOrNull() if (site == null) { wooPosLogWrapper.e("Cannot perform blocking sync: No site selected") - emit(WooPosFullSyncStatus.Failed("No site selected")) + emit(WooPosFullSyncState.Failed("No site selected")) return } wooPosLogWrapper.d("Starting blocking full sync") - emit(WooPosFullSyncStatus.InProgress) + emit(WooPosFullSyncState.InProgress) val syncResult = syncRepository.syncLocalCatalogFull(site) when (syncResult) { @@ -73,11 +67,11 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( "${syncResult.variationsSynced} variations synced " + "in ${syncResult.syncDurationMs}ms" ) - emit(WooPosFullSyncStatus.Success) + emit(WooPosFullSyncState.Success) } is PosLocalCatalogSyncResult.Failure -> { wooPosLogWrapper.e("Blocking full sync failed: ${syncResult.error}") - emit(WooPosFullSyncStatus.Failed(syncResult.error)) + emit(WooPosFullSyncState.Failed(syncResult.error)) } } } @@ -86,3 +80,9 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( private const val WORKER_STATUS_CHECK_INTERVAL_MS = 1000L } } + +sealed class WooPosFullSyncState { + data object InProgress : WooPosFullSyncState() + data object Success : WooPosFullSyncState() + data class Failed(val error: String) : WooPosFullSyncState() +} 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 index a81c427b96cf..eeb9427b9cf9 100644 --- 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 @@ -39,8 +39,8 @@ class WooPosPerformFullCatalogSyncUseCaseTest { val result = useCase().first() // THEN - assertThat(result).isInstanceOf(WooPosFullSyncStatus.Failed::class.java) - assertThat((result as WooPosFullSyncStatus.Failed).error).isEqualTo("No site selected") + assertThat(result).isInstanceOf(WooPosFullSyncState.Failed::class.java) + assertThat((result as WooPosFullSyncState.Failed).error).isEqualTo("No site selected") } @Test @@ -58,15 +58,15 @@ class WooPosPerformFullCatalogSyncUseCaseTest { whenever(syncRepository.syncLocalCatalogFull(site)).thenReturn(syncResult) // WHEN - val results = mutableListOf() + val results = mutableListOf() useCase().collect { status -> results.add(status) } // THEN assertThat(results).hasSize(2) - assertThat(results[0]).isEqualTo(WooPosFullSyncStatus.InProgress) - assertThat(results[1]).isEqualTo(WooPosFullSyncStatus.Success) + assertThat(results[0]).isEqualTo(WooPosFullSyncState.InProgress) + assertThat(results[1]).isEqualTo(WooPosFullSyncState.Success) verify(syncTimestampManager).storeFullSyncLastCompletedTimestamp(any()) } @@ -81,15 +81,15 @@ class WooPosPerformFullCatalogSyncUseCaseTest { whenever(syncRepository.syncLocalCatalogFull(site)).thenReturn(syncResult) // WHEN - val results = mutableListOf() + val results = mutableListOf() useCase().collect { status -> results.add(status) } // THEN assertThat(results).hasSize(2) - assertThat(results[0]).isEqualTo(WooPosFullSyncStatus.InProgress) - assertThat(results[1]).isInstanceOf(WooPosFullSyncStatus.Failed::class.java) - assertThat((results[1] as WooPosFullSyncStatus.Failed).error).isEqualTo("Network error") + assertThat(results[0]).isEqualTo(WooPosFullSyncState.InProgress) + assertThat(results[1]).isInstanceOf(WooPosFullSyncState.Failed::class.java) + assertThat((results[1] as WooPosFullSyncState.Failed).error).isEqualTo("Network error") } } From 03356bfe3cb7e3fb6b70d5893d943bc1286ddf09 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Tue, 7 Oct 2025 17:05:41 +0200 Subject: [PATCH 20/46] Implement warning banner --- .../home/items/WooPosRefreshCatalogBanner.kt | 91 +++++++++++++------ .../res/drawable/ic_woo_pos_info_banner.xml | 9 ++ WooCommerce/src/main/res/values/strings.xml | 2 + 3 files changed, 72 insertions(+), 30 deletions(-) create mode 100644 WooCommerce/src/main/res/drawable/ic_woo_pos_info_banner.xml diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt index 59f9ffcbdf99..e2e473b819b0 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt @@ -4,25 +4,31 @@ 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.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Info +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 @@ -30,7 +36,8 @@ import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTyp @Composable fun WooPosRefreshCatalogBanner( bannerState: WooPosItemsViewModel.CatalogSyncBannerState, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onDismiss: () -> Unit ) { AnimatedVisibility( visible = bannerState is WooPosItemsViewModel.CatalogSyncBannerState.OverdueWarning, @@ -38,33 +45,56 @@ fun WooPosRefreshCatalogBanner( animationSpec = tween(durationMillis = 180) ) + scaleIn( animationSpec = tween(durationMillis = 180) - ) + ), + modifier = modifier.padding(horizontal = WooPosSpacing.Medium.value) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.errorContainer, - shape = RoundedCornerShape(WooPosCornerRadius.Medium.value) - ) - .padding( - vertical = WooPosSpacing.Small.value, - horizontal = WooPosSpacing.Medium.value - ), + WooPosCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(WooPosCornerRadius.Medium.value), + backgroundColor = MaterialTheme.colorScheme.surfaceContainerLow, + elevation = WooPosElevation.Medium, + shadowType = ShadowType.Soft, ) { - Icon( - imageVector = Icons.Outlined.Info, - tint = MaterialTheme.colorScheme.onErrorContainer, - contentDescription = null - ) - Spacer(Modifier.size(WooPosSpacing.Small.value)) - WooPosText( - text = stringResource(R.string.woopos_refresh_catalog_banner_message), - style = WooPosTypography.BodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.weight(1f) - ) + 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) + ) + } + } } } } @@ -74,7 +104,8 @@ fun WooPosRefreshCatalogBanner( fun WooPosRefreshCatalogBannerPreview() { WooPosTheme { WooPosRefreshCatalogBanner( - bannerState = WooPosItemsViewModel.CatalogSyncBannerState.OverdueWarning + bannerState = WooPosItemsViewModel.CatalogSyncBannerState.OverdueWarning, + onDismiss = {} ) } } 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 000000000000..af0dc4bf46ce --- /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 4e035573790d..f76382adcfec 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -3599,7 +3599,9 @@ 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 From 5282805afd99c96ec02b91b32bfaeaea79dd96d7 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Tue, 7 Oct 2025 17:06:26 +0200 Subject: [PATCH 21/46] Update layouts to accommodate banner --- .../ui/woopos/home/items/WooPosItemsScreen.kt | 25 +++++++++++-------- .../home/items/coupons/WooPosCouponsScreen.kt | 6 ++--- .../items/products/WooPosProductsScreen.kt | 1 - .../variations/WooPosVariationsScreen.kt | 12 +++------ 4 files changed, 19 insertions(+), 25 deletions(-) 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 6f51e5fdb3ee..a0f766305677 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 @@ -116,6 +116,7 @@ private fun WooPosItemsScreen( }, onTabClicked = { onUIEvent(WooPosItemsUIEvent.OnTabClicked(it)) }, onBackClicked = { onUIEvent(WooPosItemsUIEvent.BackFromVariationsClicked) }, + onSyncWarningBannerDismissed = { onUIEvent(WooPosItemsUIEvent.SyncOverdueBannerDismissed) }, ) } @@ -132,15 +133,11 @@ private fun MainItemsList( onTabClicked: (WooPosItemsToolbarViewState.Tab) -> Unit, onAddCouponEvent: () -> Unit, onBackClicked: () -> Unit, + onSyncWarningBannerDismissed: () -> Unit, onRetryCatalogSync: () -> Unit = {}, ) { - Box( - modifier = modifier - .fillMaxSize() - ) { - Column( - modifier.fillMaxHeight() - ) { + Box(modifier = modifier.fillMaxSize()) { + Column(modifier.fillMaxHeight()) { WooPosItemsToolbar( modifier = Modifier .statusBarsPadding() @@ -154,14 +151,20 @@ private fun MainItemsList( onAddCouponEvent = onAddCouponEvent, ) + Spacer(modifier = + Modifier + .height(WooPosSpacing.Small.value) + .padding(horizontal = WooPosSpacing.Medium.value.toAdaptivePadding()) + ) + WooPosRefreshCatalogBanner( bannerState = bannerState.value, - modifier = Modifier.padding( - horizontal = WooPosSpacing.Medium.value.toAdaptivePadding(), - vertical = WooPosSpacing.Small.value.toAdaptivePadding(), - ) + modifier = Modifier, + onDismiss = onSyncWarningBannerDismissed ) + Spacer(modifier = Modifier.height(WooPosSpacing.Small.value)) + val currentState = state.value Crossfade( 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 dc8b22cc5af8..033627e228e0 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 @@ -21,7 +20,6 @@ import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview 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.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,7 @@ private fun WooPosCouponsScreen( when (val itemsState = state.value) { is WooPosCouponsViewState.Content -> { WooPosItemList( - modifier = Modifier.padding(top = WooPosSpacing.Large.value), + modifier = Modifier, state = itemsState, listState = listState, onItemClicked = { item -> onUIEvent(WooPosCouponsUIEvent.CouponClicked(item.id, item.name)) }, @@ -89,7 +87,7 @@ private fun WooPosCouponsScreen( } is WooPosCouponsViewState.Loading -> WooPosItemsLoadingIndicator( - modifier = Modifier.padding(top = WooPosSpacing.Large.value) + modifier = Modifier ) is WooPosCouponsViewState.Empty -> WooPosItemsEmptyList( 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 397ac5d99523..7ba9c9bff99e 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 5c63c77faa58..4b1712a2dd9d 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() } @@ -139,8 +134,7 @@ private fun WooPosVariationsScreens( is WooPosVariationsViewState.Empty -> { WooPosItemsEmptyList( - modifier = Modifier.fillMaxSize() - .padding(top = WooPosSpacing.Large.value), + modifier = Modifier.fillMaxSize(), title = stringResource(id = R.string.woopos_variations_empty_list_title), message = stringResource(id = R.string.woopos_variations_empty_list_message), contentDescription = stringResource( From 215cd5a9eb4d3b3a943c3b872a6cd38c1165d226 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Tue, 7 Oct 2025 17:07:47 +0200 Subject: [PATCH 22/46] Handle banner visibility updates --- .../android/ui/woopos/home/items/WooPosItemsScreen.kt | 4 ++-- .../android/ui/woopos/home/items/WooPosItemsUIEvent.kt | 1 + .../android/ui/woopos/home/items/WooPosItemsViewModel.kt | 7 +++++-- .../ui/woopos/home/items/WooPosRefreshCatalogBanner.kt | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) 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 a0f766305677..fd47f2dc6b19 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 @@ -324,7 +324,7 @@ fun WooPosItemsScreenSearchVisiblePreview(modifier: Modifier = Modifier) { tabs = tabs() ) ) - val bannerState = MutableStateFlow(WooPosItemsViewModel.CatalogSyncBannerState.OverdueWarning) + val bannerState = MutableStateFlow(WooPosItemsViewModel.CatalogSyncBannerState.OverdueSyncWarning) WooPosTheme { WooPosItemsScreen( modifier = modifier, @@ -354,7 +354,7 @@ fun WooPosItemsScreenSearchHiddenPreview(modifier: Modifier = Modifier) { tabs = tabs() ) ) - val bannerState = MutableStateFlow(WooPosItemsViewModel.CatalogSyncBannerState.OverdueWarning) + val bannerState = MutableStateFlow(WooPosItemsViewModel.CatalogSyncBannerState.OverdueSyncWarning) WooPosTheme { WooPosItemsScreen( modifier = modifier, 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 e8c233547568..39a7562caeab 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 bc0ee7cf8dc8..8bba55db477b 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 @@ -69,7 +69,7 @@ class WooPosItemsViewModel @Inject constructor( viewModelScope.launch { val requirement = syncStatusChecker.checkSyncRequirement() _catalogSyncBannerState.value = when (requirement) { - is WooPosFullSyncRequirement.Overdue -> CatalogSyncBannerState.OverdueWarning + is WooPosFullSyncRequirement.Overdue -> CatalogSyncBannerState.OverdueSyncWarning else -> CatalogSyncBannerState.Hidden } } @@ -95,6 +95,9 @@ class WooPosItemsViewModel @Inject constructor( } is WooPosItemsUIEvent.AddCouponIconClicked -> createAndAddCoupon() + WooPosItemsUIEvent.SyncOverdueBannerDismissed -> { + _catalogSyncBannerState.value = CatalogSyncBannerState.Hidden + } } } @@ -262,6 +265,6 @@ class WooPosItemsViewModel @Inject constructor( sealed class CatalogSyncBannerState { data object Hidden : CatalogSyncBannerState() - data object OverdueWarning : CatalogSyncBannerState() + data object OverdueSyncWarning : CatalogSyncBannerState() } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt index e2e473b819b0..e920f2cad59c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt @@ -40,7 +40,7 @@ fun WooPosRefreshCatalogBanner( onDismiss: () -> Unit ) { AnimatedVisibility( - visible = bannerState is WooPosItemsViewModel.CatalogSyncBannerState.OverdueWarning, + visible = bannerState is WooPosItemsViewModel.CatalogSyncBannerState.OverdueSyncWarning, enter = fadeIn( animationSpec = tween(durationMillis = 180) ) + scaleIn( @@ -104,7 +104,7 @@ fun WooPosRefreshCatalogBanner( fun WooPosRefreshCatalogBannerPreview() { WooPosTheme { WooPosRefreshCatalogBanner( - bannerState = WooPosItemsViewModel.CatalogSyncBannerState.OverdueWarning, + bannerState = WooPosItemsViewModel.CatalogSyncBannerState.OverdueSyncWarning, onDismiss = {} ) } From 82ea15b098fe1c80df915531c8a01a1192ed3cc9 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Tue, 7 Oct 2025 17:10:58 +0200 Subject: [PATCH 23/46] Satisfy detekt's complaints --- .../android/ui/woopos/home/items/WooPosItemsScreen.kt | 3 ++- .../ui/woopos/home/items/variations/WooPosVariationsScreen.kt | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) 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 fd47f2dc6b19..e11387ce50fb 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 @@ -151,7 +151,8 @@ private fun MainItemsList( onAddCouponEvent = onAddCouponEvent, ) - Spacer(modifier = + Spacer( + modifier = Modifier .height(WooPosSpacing.Small.value) .padding(horizontal = WooPosSpacing.Medium.value.toAdaptivePadding()) 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 4b1712a2dd9d..bbc1f7ab6820 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 @@ -4,7 +4,6 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi @@ -24,7 +23,6 @@ import com.woocommerce.android.ui.woopos.common.composeui.WooPosPreview 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.designsystem.WooPosSpacing import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTheme import com.woocommerce.android.ui.woopos.home.items.WooPosItemList import com.woocommerce.android.ui.woopos.home.items.WooPosItemSelectionViewState From f6581370c430b3d15039e20bd521385bfb202d7a Mon Sep 17 00:00:00 2001 From: samiuelson Date: Tue, 7 Oct 2025 17:24:02 +0200 Subject: [PATCH 24/46] Update FULL_SYNC_OVERDUE_THRESHOLD to 7 days --- .../ui/woopos/localcatalog/WooPosFullSyncStatusChecker.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 4eb5f9dfc4e7..52c74846ba9d 100644 --- 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 @@ -8,7 +8,7 @@ import com.woocommerce.android.ui.woopos.util.datastore.WooPosSyncTimestampManag 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.hours +import kotlin.time.Duration.Companion.days class WooPosFullSyncStatusChecker @Inject constructor( private val syncTimestampManager: WooPosSyncTimestampManager, @@ -72,7 +72,7 @@ class WooPosFullSyncStatusChecker @Inject constructor( } companion object { - private val FULL_SYNC_OVERDUE_THRESHOLD = 24.hours + private val FULL_SYNC_OVERDUE_THRESHOLD = 7.days } } From 29a0feaa2583d0e75d42f0cd0f8a5c2ed3fe01eb Mon Sep 17 00:00:00 2001 From: samiuelson Date: Tue, 7 Oct 2025 17:54:08 +0200 Subject: [PATCH 25/46] Use getWorkInfosForUniqueWorkFlow API for worker monitoring --- .../WooPosLocalCatalogSyncScheduler.kt | 22 +++-- .../WooPosPerformInstantCatalogFullSync.kt | 87 ++++++++++++++----- 2 files changed, 79 insertions(+), 30 deletions(-) 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 8af9fe2a078f..e70af6d94937 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 @@ -12,6 +12,8 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import java.util.Calendar import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -74,16 +76,24 @@ class WooPosLocalCatalogSyncScheduler @Inject constructor( logger.d("Manual POS local catalog sync triggered") } - 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() } } private fun getConstraints(): Constraints { 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 index 2db2039b51b6..c84358851a99 100644 --- 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 @@ -1,11 +1,13 @@ 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.delay 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 @@ -17,31 +19,72 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( private val wooPosLogWrapper: WooPosLogWrapper ) { operator fun invoke(): Flow = flow { - if (syncScheduler.isOneTimeWorkRunning()) { - monitorWorkerProgress() - } else { - performBlockingSync() + val isOneTimeRunning = syncScheduler.observeOneTimeWorkStatus().first() + val isPeriodicRunning = syncScheduler.observePeriodicWorkStatus().first() + + when { + isOneTimeRunning -> { + wooPosLogWrapper.d("One-time worker is running, monitoring its progress") + monitorOneTimeWorkerProgress() + } + isPeriodicRunning -> { + wooPosLogWrapper.d("Periodic worker is running, monitoring its progress") + monitorPeriodicWorkerProgress() + } + else -> { + performBlockingSync() + } } } - private suspend fun FlowCollector.monitorWorkerProgress() { - wooPosLogWrapper.d("One-time full sync worker already running, monitoring progress") + private suspend fun FlowCollector.monitorOneTimeWorkerProgress() { emit(WooPosFullSyncState.InProgress) - var workerStillRunning = true - while (workerStillRunning) { - delay(WORKER_STATUS_CHECK_INTERVAL_MS) - val completedTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() - if (completedTimestamp != null) { - wooPosLogWrapper.d("One-time worker completed successfully") - emit(WooPosFullSyncState.Success) - return + val finalWorkInfo = syncScheduler.observeOneTimeWorkInfo() + .filter { workInfo -> + workInfo?.state?.isFinished == true + } + .first() + + handleWorkerCompletion(finalWorkInfo, "One-time") + } + + private suspend fun FlowCollector.monitorPeriodicWorkerProgress() { + emit(WooPosFullSyncState.InProgress) + + // For periodic worker, we need to observe it via a different method + // Since getWorkInfosForUniqueWorkFlow returns a list, we'll get the first active one + val finalWorkInfo = syncScheduler.observePeriodicWorkInfo() + .filter { workInfo -> + workInfo?.state?.isFinished == true } - workerStillRunning = syncScheduler.isOneTimeWorkRunning() - if (!workerStillRunning) { - wooPosLogWrapper.e("One-time worker stopped without success") - emit(WooPosFullSyncState.Failed("Background sync worker failed")) - return + .first() + + handleWorkerCompletion(finalWorkInfo, "Periodic") + } + + private suspend fun FlowCollector.handleWorkerCompletion( + workInfo: WorkInfo?, + workerType: String + ) { + when (workInfo?.state) { + WorkInfo.State.SUCCEEDED -> { + val completedTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() + if (completedTimestamp != null) { + wooPosLogWrapper.d("$workerType worker completed successfully") + emit(WooPosFullSyncState.Success) + } else { + wooPosLogWrapper.e("$workerType worker succeeded but no timestamp found") + emit(WooPosFullSyncState.Failed("Worker succeeded but sync not verified")) + } + } + WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { + wooPosLogWrapper.e("$workerType worker failed or cancelled: ${workInfo.state}") + emit(WooPosFullSyncState.Failed("Background sync worker ${workInfo.state}")) + } + else -> { + wooPosLogWrapper.e("$workerType worker finished with unexpected state: ${workInfo?.state}") + emit(WooPosFullSyncState.Failed("Unexpected worker state")) } } } @@ -75,10 +118,6 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( } } } - - companion object { - private const val WORKER_STATUS_CHECK_INTERVAL_MS = 1000L - } } sealed class WooPosFullSyncState { From 8415c871f7d0aac337a6bd3316a198e7fe77eac9 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Tue, 7 Oct 2025 18:01:30 +0200 Subject: [PATCH 26/46] Update tests --- .../WooPosPerformFullCatalogSyncUseCaseTest.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 index eeb9427b9cf9..a73bf11422bd 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -33,7 +34,8 @@ class WooPosPerformFullCatalogSyncUseCaseTest { fun `given no site selected, when invoke called, then returns Failed`() = runTest { // GIVEN whenever(selectedSite.getOrNull()).thenReturn(null) - whenever(syncScheduler.isOneTimeWorkRunning()).thenReturn(false) + whenever(syncScheduler.observeOneTimeWorkStatus()).thenReturn(flowOf(false)) + whenever(syncScheduler.observePeriodicWorkStatus()).thenReturn(flowOf(false)) // WHEN val result = useCase().first() @@ -54,7 +56,8 @@ class WooPosPerformFullCatalogSyncUseCaseTest { ) whenever(selectedSite.getOrNull()).thenReturn(site) - whenever(syncScheduler.isOneTimeWorkRunning()).thenReturn(false) + whenever(syncScheduler.observeOneTimeWorkStatus()).thenReturn(flowOf(false)) + whenever(syncScheduler.observePeriodicWorkStatus()).thenReturn(flowOf(false)) whenever(syncRepository.syncLocalCatalogFull(site)).thenReturn(syncResult) // WHEN @@ -77,7 +80,8 @@ class WooPosPerformFullCatalogSyncUseCaseTest { val syncResult = PosLocalCatalogSyncResult.Failure.UnexpectedError("Network error") whenever(selectedSite.getOrNull()).thenReturn(site) - whenever(syncScheduler.isOneTimeWorkRunning()).thenReturn(false) + whenever(syncScheduler.observeOneTimeWorkStatus()).thenReturn(flowOf(false)) + whenever(syncScheduler.observePeriodicWorkStatus()).thenReturn(flowOf(false)) whenever(syncRepository.syncLocalCatalogFull(site)).thenReturn(syncResult) // WHEN From b5b0dd2cf3a4837f03f0dc7e0d6568c762721b3b Mon Sep 17 00:00:00 2001 From: samiuelson Date: Tue, 7 Oct 2025 18:02:45 +0200 Subject: [PATCH 27/46] Handle NumberFormatException --- .../ui/woopos/util/datastore/WooPosSyncTimestampRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3298a06c5ff0..686306065d35 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 @@ -95,7 +95,7 @@ class WooPosSyncTimestampRepository @Inject constructor( suspend fun getFullSyncLastCompletedTimestamp(): Long? { val key = buildSiteSpecificKey(FULL_SYNC_TIMESTAMP_KEY) return if (key != null) { - dataStore.data.first()[key]?.toLong() + dataStore.data.first()[key]?.toLongOrNull() } else { null } From fae6e2c3f9f262a4945a2b664573b60de061b37f Mon Sep 17 00:00:00 2001 From: samiuelson Date: Tue, 7 Oct 2025 18:24:39 +0200 Subject: [PATCH 28/46] Emit error in case site is null --- .../ui/woopos/localcatalog/WooPosFullSyncStatusChecker.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 52c74846ba9d..b5f95ea208eb 100644 --- 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 @@ -27,8 +27,8 @@ class WooPosFullSyncStatusChecker @Inject constructor( val site = selectedSite.getOrNull() if (site == null) { - wooPosLogWrapper.d("Full sync check skipped: No site selected") - return WooPosFullSyncRequirement.NotRequired + wooPosLogWrapper.e("Full sync check failed: No site selected") + return WooPosFullSyncRequirement.Error("No site selected") } if (!networkStatus.isConnected()) { From 0977a062f618ab597e13872206e41908bf0f45cc Mon Sep 17 00:00:00 2001 From: samiuelson Date: Tue, 7 Oct 2025 18:25:02 +0200 Subject: [PATCH 29/46] Remove comment --- .../woopos/localcatalog/WooPosPerformInstantCatalogFullSync.kt | 2 -- 1 file changed, 2 deletions(-) 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 index c84358851a99..bc8df1d003c6 100644 --- 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 @@ -52,8 +52,6 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( private suspend fun FlowCollector.monitorPeriodicWorkerProgress() { emit(WooPosFullSyncState.InProgress) - // For periodic worker, we need to observe it via a different method - // Since getWorkInfosForUniqueWorkFlow returns a list, we'll get the first active one val finalWorkInfo = syncScheduler.observePeriodicWorkInfo() .filter { workInfo -> workInfo?.state?.isFinished == true From 1c67f5bb396ccacb1a6927e9e809451e8a798557 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Tue, 7 Oct 2025 18:34:17 +0200 Subject: [PATCH 30/46] Clean up code --- .../android/ui/woopos/home/items/WooPosItemsScreen.kt | 1 - .../ui/woopos/home/items/coupons/WooPosCouponsScreen.kt | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) 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 e11387ce50fb..bec4099fc6ec 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 @@ -160,7 +160,6 @@ private fun MainItemsList( WooPosRefreshCatalogBanner( bannerState = bannerState.value, - modifier = Modifier, onDismiss = onSyncWarningBannerDismissed ) 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 033627e228e0..1c0f0b020c1d 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 @@ -72,7 +72,6 @@ private fun WooPosCouponsScreen( when (val itemsState = state.value) { is WooPosCouponsViewState.Content -> { WooPosItemList( - modifier = Modifier, state = itemsState, listState = listState, onItemClicked = { item -> onUIEvent(WooPosCouponsUIEvent.CouponClicked(item.id, item.name)) }, @@ -86,9 +85,7 @@ private fun WooPosCouponsScreen( } } - is WooPosCouponsViewState.Loading -> WooPosItemsLoadingIndicator( - modifier = Modifier - ) + is WooPosCouponsViewState.Loading -> WooPosItemsLoadingIndicator() is WooPosCouponsViewState.Empty -> WooPosItemsEmptyList( modifier = Modifier.fillMaxSize(), From 84eb52d8707af7765a1d352b457418f5b00bb5e3 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Tue, 7 Oct 2025 18:35:46 +0200 Subject: [PATCH 31/46] Clean up code --- .../ui/woopos/home/items/WooPosRefreshCatalogBanner.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt index e920f2cad59c..9be2217c0aee 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt @@ -65,7 +65,9 @@ fun WooPosRefreshCatalogBanner( painter = painterResource(id = R.drawable.ic_woo_pos_info_banner), tint = MaterialTheme.colorScheme.onSurface, contentDescription = null, - modifier = Modifier.size(48.dp).align(Alignment.CenterVertically) + modifier = Modifier + .size(48.dp) + .align(Alignment.CenterVertically) ) Column( verticalArrangement = Arrangement.spacedBy(WooPosSpacing.Small.value), From afed69091c9d3e06bf74838b40f39ce562cdc518 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Wed, 15 Oct 2025 17:29:41 +0200 Subject: [PATCH 32/46] Move initial full sync to splash screen --- .../ui/woopos/home/WooPosHomeScreen.kt | 14 +-- .../android/ui/woopos/home/WooPosHomeState.kt | 16 ---- .../ui/woopos/home/WooPosHomeUIEvent.kt | 1 - .../ui/woopos/home/WooPosHomeViewModel.kt | 48 +--------- .../ui/woopos/home/items/WooPosItemsScreen.kt | 95 +------------------ .../ui/woopos/splash/WooPosSplashScreen.kt | 72 +++++++++++++- .../ui/woopos/splash/WooPosSplashState.kt | 2 + .../ui/woopos/splash/WooPosSplashViewModel.kt | 56 ++++++++++- 8 files changed, 128 insertions(+), 176 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt index 573e08e61e31..764b9dc32b81 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt @@ -116,7 +116,6 @@ private fun WooPosHomeScreen( cartWidthDp = cartWidthDp, totalsWidthDp = totalsWidthAnimatedDp, onHomeUIEvent = onHomeUIEvent, - onRetryCatalogSyncClicked = { onHomeUIEvent(WooPosHomeUIEvent.RetryCatalogSyncClicked) }, ) } @@ -128,7 +127,6 @@ private fun WooPosHomeScreen( cartWidthDp: Dp, totalsWidthDp: Dp, onHomeUIEvent: (WooPosHomeUIEvent) -> Unit, - onRetryCatalogSyncClicked: () -> Unit = {}, ) { Box( modifier = Modifier @@ -151,9 +149,7 @@ private fun WooPosHomeScreen( ) { WooPosHomeScreenProducts( modifier = Modifier - .width(productsWidthDp), - catalogSyncState = state.catalogSyncState, - onRetryCatalogSyncClicked = onRetryCatalogSyncClicked + .width(productsWidthDp) ) WooPosHomeScreenCart( modifier = Modifier @@ -200,17 +196,13 @@ private fun Dialogs( @Composable private fun WooPosHomeScreenProducts( - modifier: Modifier, - catalogSyncState: WooPosHomeState.CatalogSyncState = WooPosHomeState.CatalogSyncState.Idle, - onRetryCatalogSyncClicked: () -> Unit = {} + modifier: Modifier ) { if (isPreviewMode()) { WooPosItemsScreenPreview(modifier) } else { WooPosItemsScreen( - modifier = modifier, - catalogSyncState = catalogSyncState, - onRetryCatalogSyncClicked = onRetryCatalogSyncClicked + modifier = modifier ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt index 5a6f96470389..2c3fb16b1add 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeState.kt @@ -9,7 +9,6 @@ import kotlinx.parcelize.Parcelize data class WooPosHomeState( val screenPositionState: ScreenPositionState, val dialogState: DialogState = DialogState.Hidden, - val catalogSyncState: CatalogSyncState = CatalogSyncState.Idle, ) : Parcelable { @Parcelize sealed class ScreenPositionState : Parcelable { @@ -46,19 +45,4 @@ data class WooPosHomeState( val confirmButton: Int = R.string.woopos_exit_dialog_confirmation_confirm_button } } - - @Parcelize - sealed class CatalogSyncState : Parcelable { - @Parcelize - data object Idle : CatalogSyncState() - - @Parcelize - data object Syncing : CatalogSyncState() - - @Parcelize - data object Success : CatalogSyncState() - - @Parcelize - data class Failed(val error: String) : CatalogSyncState() - } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeUIEvent.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeUIEvent.kt index 42c4a7e8fee8..319e9f7d981b 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeUIEvent.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeUIEvent.kt @@ -8,7 +8,6 @@ sealed class WooPosHomeUIEvent { data object DismissScanningSetupDialog : WooPosHomeUIEvent() data object OnPaymentCompletedViaCash : WooPosHomeUIEvent() data object ExitPosClicked : WooPosHomeUIEvent() - data object RetryCatalogSyncClicked : WooPosHomeUIEvent() data class OnBarcodeEvent( val result: BarcodeInputDetector.BarcodeResult ) : WooPosHomeUIEvent() 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 4decbc043827..4812d46a56fe 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,11 +14,7 @@ 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.WooPosFullSyncRequirement -import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncState -import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncStatusChecker import com.woocommerce.android.ui.woopos.localcatalog.WooPosIncrementalSyncReason -import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformInstantCatalogFullSync 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 @@ -37,8 +33,6 @@ class WooPosHomeViewModel @Inject constructor( private val parentToChildrenEventSender: WooPosParentToChildrenEventSender, private val analyticsTracker: WooPosAnalyticsTracker, private val soundHelper: WooPosSoundHelper, - private val syncStatusChecker: WooPosFullSyncStatusChecker, - private val performInitialFullSync: WooPosPerformInstantCatalogFullSync, private val incrementalSync: WooPosPerformLocalCatalogIncrementalSync, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -68,43 +62,7 @@ class WooPosHomeViewModel @Inject constructor( private fun startCatalogSyncIfNeeded() { viewModelScope.launch { - val requirement = syncStatusChecker.checkSyncRequirement() - - when (requirement) { - is WooPosFullSyncRequirement.NotRequired, - is WooPosFullSyncRequirement.Overdue -> { - _state.value = _state.value.copy( - catalogSyncState = WooPosHomeState.CatalogSyncState.Idle - ) - incrementalSync.execute(WooPosIncrementalSyncReason.ON_POS_HOME) - } - is WooPosFullSyncRequirement.BlockingRequired -> { - performInitialFullSync().collect { syncStatus -> - when (syncStatus) { - is WooPosFullSyncState.InProgress -> { - _state.value = _state.value.copy( - catalogSyncState = WooPosHomeState.CatalogSyncState.Syncing - ) - } - is WooPosFullSyncState.Success -> { - _state.value = _state.value.copy( - catalogSyncState = WooPosHomeState.CatalogSyncState.Success - ) - } - is WooPosFullSyncState.Failed -> { - _state.value = _state.value.copy( - catalogSyncState = WooPosHomeState.CatalogSyncState.Failed(syncStatus.error) - ) - } - } - } - } - is WooPosFullSyncRequirement.Error -> { - _state.value = _state.value.copy( - catalogSyncState = WooPosHomeState.CatalogSyncState.Failed(requirement.message) - ) - } - } + incrementalSync.execute(WooPosIncrementalSyncReason.ON_POS_HOME) } } @@ -140,10 +98,6 @@ class WooPosHomeViewModel @Inject constructor( } } - WooPosHomeUIEvent.RetryCatalogSyncClicked -> { - startCatalogSyncIfNeeded() - } - is WooPosHomeUIEvent.OnBarcodeEvent -> { sendEventToChildren(ParentToChildrenEvent.BarcodeEvent(event.result)) } 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 ff78c9e04d90..0140c34469c4 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 @@ -3,42 +3,26 @@ package com.woocommerce.android.ui.woopos.home.items import androidx.compose.animation.Crossfade import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.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.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State 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.component.WooPosSearchInputState import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosSearchUIEvent 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.toAdaptivePadding -import com.woocommerce.android.ui.woopos.home.WooPosHomeState.CatalogSyncState import com.woocommerce.android.ui.woopos.home.items.WooPosItemsToolbarViewState.Tab.Coupons import com.woocommerce.android.ui.woopos.home.items.WooPosItemsToolbarViewState.Tab.HighlightLevel import com.woocommerce.android.ui.woopos.home.items.WooPosItemsToolbarViewState.Tab.Products @@ -55,9 +39,7 @@ import kotlinx.coroutines.flow.StateFlow @OptIn(ExperimentalMaterialApi::class) @Composable fun WooPosItemsScreen( - modifier: Modifier = Modifier, - catalogSyncState: CatalogSyncState = CatalogSyncState.Idle, - onRetryCatalogSyncClicked: () -> Unit = {} + modifier: Modifier = Modifier ) { val productsViewState = rememberLazyListState() val couponsListState = rememberLazyListState() @@ -67,8 +49,6 @@ fun WooPosItemsScreen( itemsStateFlow = productsViewModel.viewState, productsViewState = productsViewState, couponsListState = couponsListState, - catalogSyncState = catalogSyncState, - onRetryCatalogSync = onRetryCatalogSyncClicked, onUIEvent = { productsViewModel.onUIEvent(it) }, ) } @@ -80,8 +60,6 @@ private fun WooPosItemsScreen( itemsStateFlow: StateFlow, productsViewState: LazyListState, couponsListState: LazyListState, - catalogSyncState: CatalogSyncState, - onRetryCatalogSync: () -> Unit, onUIEvent: (WooPosItemsUIEvent) -> Unit, ) { val state = itemsStateFlow.collectAsState() @@ -91,8 +69,6 @@ private fun WooPosItemsScreen( state = state, productsViewState = productsViewState, couponsListState = couponsListState, - catalogSyncState = catalogSyncState, - onRetryCatalogSync = onRetryCatalogSync, onSearchEvent = { when (it) { WooPosSearchUIEvent.Clear -> onUIEvent(WooPosItemsUIEvent.ClearSearchClicked) @@ -122,12 +98,10 @@ private fun MainItemsList( state: State, productsViewState: LazyListState, couponsListState: LazyListState, - catalogSyncState: CatalogSyncState, onSearchEvent: (WooPosSearchUIEvent) -> Unit, onTabClicked: (WooPosItemsToolbarViewState.Tab) -> Unit, onAddCouponEvent: () -> Unit, onBackClicked: () -> Unit, - onRetryCatalogSync: () -> Unit = {}, ) { Box( modifier = modifier @@ -188,14 +162,6 @@ private fun MainItemsList( } } - if (catalogSyncState is CatalogSyncState.Syncing || - catalogSyncState is CatalogSyncState.Failed - ) { - CatalogSyncOverlay( - catalogSyncState = catalogSyncState, - onRetryClicked = onRetryCatalogSync - ) - } } } @@ -238,61 +204,6 @@ private fun getScreenState(state: WooPosItemsToolbarViewState): ScreenState { } } -@Composable -private fun CatalogSyncOverlay( - catalogSyncState: CatalogSyncState, - onRetryClicked: () -> Unit = {} -) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)), - contentAlignment = Alignment.Center - ) { - when (catalogSyncState) { - is CatalogSyncState.Syncing -> { - SyncingCatalogContent() - } - is CatalogSyncState.Failed -> { - SyncFailedContent(onRetryClicked = onRetryClicked) - } - else -> { - // Should not happen, but handle gracefully - } - } - } -} - -@Suppress("WooPosDesignSystemSpacingUsageRule", "WooPosDesignSystemTextUsageRule") -@Composable -private fun SyncingCatalogContent() { - 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 SyncFailedContent(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 - ) - ) -} - @OptIn(ExperimentalMaterialApi::class) @Composable @WooPosPreview @@ -314,8 +225,6 @@ fun WooPosItemsScreenSearchVisiblePreview(modifier: Modifier = Modifier) { itemsStateFlow = productState, productsViewState = rememberLazyListState(), couponsListState = rememberLazyListState(), - catalogSyncState = CatalogSyncState.Idle, - onRetryCatalogSync = {}, onUIEvent = {}, ) } @@ -342,8 +251,6 @@ fun WooPosItemsScreenSearchHiddenPreview(modifier: Modifier = Modifier) { itemsStateFlow = productState, productsViewState = rememberLazyListState(), couponsListState = rememberLazyListState(), - catalogSyncState = CatalogSyncState.Idle, - onRetryCatalogSync = {}, onUIEvent = {}, ) } 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 0b21c7262e5b..62a6271285f8 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 84bd3f34871c..914da9b8e6ec 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 6448aa1e1b52..acfe3fb7686d 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,6 +4,10 @@ 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.WooPosFullSyncRequirement +import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncState +import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncStatusChecker +import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformInstantCatalogFullSync import com.woocommerce.android.ui.woopos.orders.WooPosOrdersInMemoryCache import com.woocommerce.android.ui.woopos.tab.WooPosCanBeLaunchedInTab import com.woocommerce.android.ui.woopos.tab.WooPosLaunchability @@ -24,6 +28,8 @@ class WooPosSplashViewModel @Inject constructor( private val analyticsTracker: WooPosAnalyticsTracker, private val posCanBeLaunchedInTab: WooPosCanBeLaunchedInTab, private val ordersCache: WooPosOrdersInMemoryCache, + private val syncStatusChecker: WooPosFullSyncStatusChecker, + private val performInitialFullSync: WooPosPerformInstantCatalogFullSync, ) : ViewModel() { private val _state = MutableStateFlow(WooPosSplashState.Loading) val state: StateFlow = _state @@ -44,8 +50,54 @@ class WooPosSplashViewModel @Inject constructor( launch { popularProductsProvider.fetchAndCachePopularProducts() }, launch { ordersCache.clear() } ) - _state.value = WooPosSplashState.Loaded - trackPosLoaded(splashScreenStartTime) + + val requirement = syncStatusChecker.checkSyncRequirement() + when (requirement) { + is WooPosFullSyncRequirement.NotRequired, + is WooPosFullSyncRequirement.Overdue -> { + _state.value = WooPosSplashState.Loaded + trackPosLoaded(splashScreenStartTime) + } + is WooPosFullSyncRequirement.BlockingRequired -> { + _state.value = WooPosSplashState.Syncing + performInitialFullSync().collect { syncStatus -> + when (syncStatus) { + is WooPosFullSyncState.InProgress -> { + _state.value = WooPosSplashState.Syncing + } + is WooPosFullSyncState.Success -> { + _state.value = WooPosSplashState.Loaded + trackPosLoaded(splashScreenStartTime) + } + is WooPosFullSyncState.Failed -> { + _state.value = WooPosSplashState.SyncFailed(syncStatus.error) + } + } + } + } + is WooPosFullSyncRequirement.Error -> { + _state.value = WooPosSplashState.SyncFailed(requirement.message) + } + } + } + } + + fun onRetrySync() { + viewModelScope.launch { + _state.value = WooPosSplashState.Syncing + performInitialFullSync().collect { syncStatus -> + when (syncStatus) { + is WooPosFullSyncState.InProgress -> { + _state.value = WooPosSplashState.Syncing + } + is WooPosFullSyncState.Success -> { + _state.value = WooPosSplashState.Loaded + } + is WooPosFullSyncState.Failed -> { + _state.value = WooPosSplashState.SyncFailed(syncStatus.error) + } + } + } } } From 88fdeab6bfa364964e9b5cdac26936f36e69c9da Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 16 Oct 2025 06:57:07 +0200 Subject: [PATCH 33/46] Clean up code --- ...ooPosPerformLocalCatalogInitialFullSync.kt | 46 +++++++++++ .../ui/woopos/splash/WooPosSplashViewModel.kt | 77 +++++++------------ .../ui/woopos/home/WooPosHomeViewModelTest.kt | 12 --- .../splash/WooPosSplashViewModelTest.kt | 23 ++++-- 4 files changed, 91 insertions(+), 67 deletions(-) create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosPerformLocalCatalogInitialFullSync.kt 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 000000000000..7ee0c0e7b19e --- /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.Ready) + } + 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 Ready : 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/WooPosSplashViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModel.kt index acfe3fb7686d..c41ca8e535c1 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,16 +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.WooPosFullSyncRequirement -import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncState -import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncStatusChecker -import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformInstantCatalogFullSync +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 @@ -28,8 +27,7 @@ class WooPosSplashViewModel @Inject constructor( private val analyticsTracker: WooPosAnalyticsTracker, private val posCanBeLaunchedInTab: WooPosCanBeLaunchedInTab, private val ordersCache: WooPosOrdersInMemoryCache, - private val syncStatusChecker: WooPosFullSyncStatusChecker, - private val performInitialFullSync: WooPosPerformInstantCatalogFullSync, + private val performInitialFullSync: WooPosPerformLocalCatalogInitialFullSync, ) : ViewModel() { private val _state = MutableStateFlow(WooPosSplashState.Loading) val state: StateFlow = _state @@ -51,60 +49,43 @@ class WooPosSplashViewModel @Inject constructor( launch { ordersCache.clear() } ) - val requirement = syncStatusChecker.checkSyncRequirement() - when (requirement) { - is WooPosFullSyncRequirement.NotRequired, - is WooPosFullSyncRequirement.Overdue -> { - _state.value = WooPosSplashState.Loaded - trackPosLoaded(splashScreenStartTime) - } - is WooPosFullSyncRequirement.BlockingRequired -> { - _state.value = WooPosSplashState.Syncing - performInitialFullSync().collect { syncStatus -> - when (syncStatus) { - is WooPosFullSyncState.InProgress -> { - _state.value = WooPosSplashState.Syncing - } - is WooPosFullSyncState.Success -> { - _state.value = WooPosSplashState.Loaded - trackPosLoaded(splashScreenStartTime) - } - is WooPosFullSyncState.Failed -> { - _state.value = WooPosSplashState.SyncFailed(syncStatus.error) - } - } - } - } - is WooPosFullSyncRequirement.Error -> { - _state.value = WooPosSplashState.SyncFailed(requirement.message) - } - } + performInitialFullSync().collect(syncStateCollector(splashScreenStartTime)) } } fun onRetrySync() { viewModelScope.launch { + val retryStartTime = System.currentTimeMillis() _state.value = WooPosSplashState.Syncing - performInitialFullSync().collect { syncStatus -> - when (syncStatus) { - is WooPosFullSyncState.InProgress -> { - _state.value = WooPosSplashState.Syncing - } - is WooPosFullSyncState.Success -> { - _state.value = WooPosSplashState.Loaded - } - is WooPosFullSyncState.Failed -> { - _state.value = WooPosSplashState.SyncFailed(syncStatus.error) - } - } + performInitialFullSync().collect(syncStateCollector(retryStartTime)) + } + } + + private fun syncStateCollector( + startTime: Long + ) = FlowCollector { syncState -> + when (syncState) { + is WooPosLocalCatalogInitialFullSyncState.Ready -> { + _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/test/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeViewModelTest.kt index 0f1bbfa156b6..6d6cbf6f1d10 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,9 +7,6 @@ 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.WooPosFullSyncRequirement -import com.woocommerce.android.ui.woopos.localcatalog.WooPosFullSyncStatusChecker -import com.woocommerce.android.ui.woopos.localcatalog.WooPosPerformInstantCatalogFullSync 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 @@ -18,7 +15,6 @@ import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.Rule @@ -42,8 +38,6 @@ class WooPosHomeViewModelTest { private val parentToChildrenEventSender: WooPosParentToChildrenEventSender = mock() private val analyticsTracker: WooPosAnalyticsTracker = mock() private val soundHelper: WooPosSoundHelper = mock() - private val syncStatusChecker: WooPosFullSyncStatusChecker = mock() - private val performInitialFullSync: WooPosPerformInstantCatalogFullSync = mock() private val incrementalSync: WooPosPerformLocalCatalogIncrementalSync = mock() @Test @@ -350,17 +344,11 @@ class WooPosHomeViewModelTest { } private fun createViewModel(): WooPosHomeViewModel { - whenever(runBlocking { syncStatusChecker.checkSyncRequirement() }).thenReturn( - WooPosFullSyncRequirement.NotRequired - ) - return WooPosHomeViewModel( childrenToParentEventReceiver, parentToChildrenEventSender, analyticsTracker, soundHelper, - syncStatusChecker, - performInitialFullSync, incrementalSync, SavedStateHandle() ) 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 15d3fe8bf8b2..f82c15880d2b 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,6 +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.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 @@ -9,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 @@ -28,6 +31,7 @@ class WooPosSplashViewModelTest { private val analyticsTracker: WooPosAnalyticsTracker = mock() private val popularProductsProvider: WooPosPopularProductsProvider = mock() private val posCanBeLaunchedInTab: WooPosCanBeLaunchedInTab = mock() + private val performInitialFullSync: WooPosPerformLocalCatalogInitialFullSync = mock() @Rule @JvmField @@ -152,11 +156,16 @@ class WooPosSplashViewModelTest { assertThat(sut.state.value).isEqualTo(WooPosSplashState.Loaded) } - private fun createSut() = WooPosSplashViewModel( - productsDataSource, - popularProductsProvider, - analyticsTracker, - posCanBeLaunchedInTab, - ordersCache, - ) + private fun createSut(): WooPosSplashViewModel { + whenever(performInitialFullSync()).thenReturn(flowOf(WooPosLocalCatalogInitialFullSyncState.Ready)) + + return WooPosSplashViewModel( + productsDataSource, + popularProductsProvider, + analyticsTracker, + posCanBeLaunchedInTab, + ordersCache, + performInitialFullSync, + ) + } } From 1bed6b0bc224ae5cce61059c651b1dc2dd6d6888 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 16 Oct 2025 07:34:11 +0200 Subject: [PATCH 34/46] Allow entering POS with empty catalog --- .../ui/woopos/home/items/WooPosItemsScreen.kt | 1 - .../WooPosFullSyncStatusChecker.kt | 35 +++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) 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 0140c34469c4..426f1480f0d2 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 @@ -161,7 +161,6 @@ private fun MainItemsList( } } } - } } 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 index b5f95ea208eb..41e3b6494550 100644 --- 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 @@ -31,11 +31,6 @@ class WooPosFullSyncStatusChecker @Inject constructor( return WooPosFullSyncRequirement.Error("No site selected") } - if (!networkStatus.isConnected()) { - wooPosLogWrapper.d("Full sync check skipped: No network connection") - return WooPosFullSyncRequirement.Error("No network connection") - } - val lastFullSyncTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() val productCount = localCatalogStore.getProductCount(LocalOrRemoteId.LocalId(site.id)) .getOrElse { @@ -44,21 +39,31 @@ class WooPosFullSyncStatusChecker @Inject constructor( } val catalogIsEmpty = productCount == 0 - return when { - lastFullSyncTimestamp == null -> { - wooPosLogWrapper.d("Full sync required: Never synced before") - WooPosFullSyncRequirement.BlockingRequired - } - catalogIsEmpty -> { - wooPosLogWrapper.d("Full sync required: Catalog is empty") - WooPosFullSyncRequirement.BlockingRequired + 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) -> { - wooPosLogWrapper.d("Full sync overdue (last sync: $lastFullSyncTimestamp), showing banner") + 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 found at $lastFullSyncTimestamp") + wooPosLogWrapper.d( + "Full sync not required: Recent sync at $lastFullSyncTimestamp " + + "(${if (catalogIsEmpty) "empty catalog" else "$productCount products"})" + ) WooPosFullSyncRequirement.NotRequired } } From 6190d970c66112e712dd8bd58ef689c84283856e Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 16 Oct 2025 10:14:00 +0200 Subject: [PATCH 35/46] Improve worker completion monitoring --- .../WooPosPerformInstantCatalogFullSync.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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 index bc8df1d003c6..663b3c9c9d59 100644 --- 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 @@ -42,7 +42,7 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( val finalWorkInfo = syncScheduler.observeOneTimeWorkInfo() .filter { workInfo -> - workInfo?.state?.isFinished == true + workInfo?.state?.isFinished == true || workInfo == null } .first() @@ -54,7 +54,7 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( val finalWorkInfo = syncScheduler.observePeriodicWorkInfo() .filter { workInfo -> - workInfo?.state?.isFinished == true + workInfo?.state?.isFinished == true || workInfo == null } .first() @@ -80,9 +80,19 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( wooPosLogWrapper.e("$workerType worker failed or cancelled: ${workInfo.state}") emit(WooPosFullSyncState.Failed("Background sync worker ${workInfo.state}")) } + null -> { + val completedTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() + if (completedTimestamp != null) { + wooPosLogWrapper.d("$workerType worker info is null but sync timestamp found - assuming success") + emit(WooPosFullSyncState.Success) + } else { + wooPosLogWrapper.e("$workerType worker info is null and no sync timestamp found") + emit(WooPosFullSyncState.Failed("Worker completed but sync not verified")) + } + } else -> { - wooPosLogWrapper.e("$workerType worker finished with unexpected state: ${workInfo?.state}") - emit(WooPosFullSyncState.Failed("Unexpected worker state")) + wooPosLogWrapper.e("$workerType worker finished with unexpected state: ${workInfo.state}") + emit(WooPosFullSyncState.Failed("Unexpected worker state: ${workInfo.state}")) } } } From ec3a521d6f9513819a8e61ce33d5d4d406b3bbbb Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 16 Oct 2025 14:55:58 +0200 Subject: [PATCH 36/46] Add tests for WooPosFullSyncStatusCheckerTest --- .../WooPosFullSyncStatusCheckerTest.kt | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/localcatalog/WooPosFullSyncStatusCheckerTest.kt 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 000000000000..5433357393c3 --- /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) + } +} From fe6375a8e7e9a90ada40f4f2f4ab7a9b2450e49e Mon Sep 17 00:00:00 2001 From: Samuel Urbanowicz Date: Thu, 16 Oct 2025 15:26:01 +0200 Subject: [PATCH 37/46] Clean up code Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../android/ui/woopos/home/items/WooPosItemsScreen.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 426f1480f0d2..b3cb489ce078 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 @@ -38,9 +38,7 @@ import kotlinx.coroutines.flow.StateFlow @OptIn(ExperimentalMaterialApi::class) @Composable -fun WooPosItemsScreen( - modifier: Modifier = Modifier -) { +fun WooPosItemsScreen(modifier: Modifier = Modifier) { val productsViewState = rememberLazyListState() val couponsListState = rememberLazyListState() val productsViewModel: WooPosItemsViewModel = hiltViewModel() From 984b76d63b59000b725d53a8ab8216614787e29a Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 16 Oct 2025 15:45:25 +0200 Subject: [PATCH 38/46] Clean up code --- .../woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt index 764b9dc32b81..85075cf40295 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt @@ -195,9 +195,7 @@ private fun Dialogs( } @Composable -private fun WooPosHomeScreenProducts( - modifier: Modifier -) { +private fun WooPosHomeScreenProducts(modifier: Modifier) { if (isPreviewMode()) { WooPosItemsScreenPreview(modifier) } else { From c3a3d32701e23be6afa38be2d7c25a5698078e2d Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 16 Oct 2025 15:46:02 +0200 Subject: [PATCH 39/46] Clean up code --- .../woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt index 85075cf40295..0a4c631cf69e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/WooPosHomeScreen.kt @@ -199,9 +199,7 @@ private fun WooPosHomeScreenProducts(modifier: Modifier) { if (isPreviewMode()) { WooPosItemsScreenPreview(modifier) } else { - WooPosItemsScreen( - modifier = modifier - ) + WooPosItemsScreen(modifier = modifier) } } From 6169c46e5b53695d29909aeb3e3171247a17ab94 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 16 Oct 2025 15:48:01 +0200 Subject: [PATCH 40/46] Rename method --- .../woocommerce/android/ui/woopos/home/WooPosHomeViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 4812d46a56fe..389462231202 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 @@ -57,10 +57,10 @@ class WooPosHomeViewModel @Inject constructor( viewModelScope.launch { soundHelper.preloadChaChing() } - startCatalogSyncIfNeeded() + performLocalCatalogIncrementalSync() } - private fun startCatalogSyncIfNeeded() { + private fun performLocalCatalogIncrementalSync() { viewModelScope.launch { incrementalSync.execute(WooPosIncrementalSyncReason.ON_POS_HOME) } From 33cfa10caffad361472be632ce5b209b8ee308d4 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Thu, 16 Oct 2025 15:57:26 +0200 Subject: [PATCH 41/46] Clean up code --- .../WooPosPerformInstantCatalogFullSync.kt | 60 +++++++++---------- 1 file changed, 27 insertions(+), 33 deletions(-) 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 index 663b3c9c9d59..e83acddf2256 100644 --- 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 @@ -25,11 +25,11 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( when { isOneTimeRunning -> { wooPosLogWrapper.d("One-time worker is running, monitoring its progress") - monitorOneTimeWorkerProgress() + monitorWorkerProgress(syncScheduler.observeOneTimeWorkInfo(), "One-time") } isPeriodicRunning -> { wooPosLogWrapper.d("Periodic worker is running, monitoring its progress") - monitorPeriodicWorkerProgress() + monitorWorkerProgress(syncScheduler.observePeriodicWorkInfo(), "Periodic") } else -> { performBlockingSync() @@ -37,28 +37,19 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( } } - private suspend fun FlowCollector.monitorOneTimeWorkerProgress() { - emit(WooPosFullSyncState.InProgress) - - val finalWorkInfo = syncScheduler.observeOneTimeWorkInfo() - .filter { workInfo -> - workInfo?.state?.isFinished == true || workInfo == null - } - .first() - - handleWorkerCompletion(finalWorkInfo, "One-time") - } - - private suspend fun FlowCollector.monitorPeriodicWorkerProgress() { + private suspend fun FlowCollector.monitorWorkerProgress( + workInfoFlow: Flow, + workerType: String + ) { emit(WooPosFullSyncState.InProgress) - val finalWorkInfo = syncScheduler.observePeriodicWorkInfo() + val finalWorkInfo = workInfoFlow .filter { workInfo -> workInfo?.state?.isFinished == true || workInfo == null } .first() - handleWorkerCompletion(finalWorkInfo, "Periodic") + handleWorkerCompletion(finalWorkInfo, workerType) } private suspend fun FlowCollector.handleWorkerCompletion( @@ -67,28 +58,17 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( ) { when (workInfo?.state) { WorkInfo.State.SUCCEEDED -> { - val completedTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() - if (completedTimestamp != null) { - wooPosLogWrapper.d("$workerType worker completed successfully") - emit(WooPosFullSyncState.Success) - } else { - wooPosLogWrapper.e("$workerType worker succeeded but no timestamp found") - emit(WooPosFullSyncState.Failed("Worker succeeded but sync not verified")) - } + 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 -> { - val completedTimestamp = syncTimestampManager.getFullSyncLastCompletedTimestamp() - if (completedTimestamp != null) { - wooPosLogWrapper.d("$workerType worker info is null but sync timestamp found - assuming success") - emit(WooPosFullSyncState.Success) - } else { - wooPosLogWrapper.e("$workerType worker info is null and no sync timestamp found") - emit(WooPosFullSyncState.Failed("Worker completed but sync not verified")) - } + verifyAndEmitSyncCompletion( + workerType, + "worker info is null but sync timestamp found - assuming success" + ) } else -> { wooPosLogWrapper.e("$workerType worker finished with unexpected state: ${workInfo.state}") @@ -97,6 +77,20 @@ class WooPosPerformInstantCatalogFullSync @Inject constructor( } } + 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) { From 59358ad7e3f305a94b40c679c085c5606251cb30 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Fri, 17 Oct 2025 16:31:56 +0200 Subject: [PATCH 42/46] Rename val --- .../android/ui/woopos/home/items/WooPosItemsScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 bec4099fc6ec..f096bcc1a0fc 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 @@ -87,12 +87,12 @@ private fun WooPosItemsScreen( onUIEvent: (WooPosItemsUIEvent) -> Unit, ) { val state = itemsStateFlow.collectAsState() - val bannerState = catalogSyncBannerStateFlow.collectAsState() + val catalogSyncOverdueBannerState = catalogSyncBannerStateFlow.collectAsState() MainItemsList( modifier = modifier, state = state, - bannerState = bannerState, + bannerState = catalogSyncOverdueBannerState, productsViewState = productsViewState, couponsListState = couponsListState, catalogSyncState = catalogSyncState, From 11fac8f350c3f8d61d25854a34741568780b6043 Mon Sep 17 00:00:00 2001 From: samiuelson Date: Fri, 17 Oct 2025 16:33:23 +0200 Subject: [PATCH 43/46] Rename composable --- ...reshCatalogBanner.kt => WooPosCatalogSyncOverdueBanner.kt} | 4 ++-- .../android/ui/woopos/home/items/WooPosItemsScreen.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/{WooPosRefreshCatalogBanner.kt => WooPosCatalogSyncOverdueBanner.kt} (98%) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosCatalogSyncOverdueBanner.kt similarity index 98% rename from WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt rename to WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosCatalogSyncOverdueBanner.kt index 9be2217c0aee..3c3a71766ebd 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosRefreshCatalogBanner.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/items/WooPosCatalogSyncOverdueBanner.kt @@ -34,7 +34,7 @@ import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosThe import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTypography @Composable -fun WooPosRefreshCatalogBanner( +fun WooPosCatalogSyncOverdueBanner( bannerState: WooPosItemsViewModel.CatalogSyncBannerState, modifier: Modifier = Modifier, onDismiss: () -> Unit @@ -105,7 +105,7 @@ fun WooPosRefreshCatalogBanner( @WooPosPreview fun WooPosRefreshCatalogBannerPreview() { WooPosTheme { - WooPosRefreshCatalogBanner( + WooPosCatalogSyncOverdueBanner( bannerState = WooPosItemsViewModel.CatalogSyncBannerState.OverdueSyncWarning, 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 f096bcc1a0fc..85852f06cf68 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 @@ -158,7 +158,7 @@ private fun MainItemsList( .padding(horizontal = WooPosSpacing.Medium.value.toAdaptivePadding()) ) - WooPosRefreshCatalogBanner( + WooPosCatalogSyncOverdueBanner( bannerState = bannerState.value, onDismiss = onSyncWarningBannerDismissed ) From 929e1d75e900a5cbbf3491a8d8e017378e37c53c Mon Sep 17 00:00:00 2001 From: samiuelson Date: Fri, 17 Oct 2025 16:36:24 +0200 Subject: [PATCH 44/46] Improve naming of state class and vals --- .../items/WooPosCatalogSyncOverdueBanner.kt | 6 +++--- .../ui/woopos/home/items/WooPosItemsScreen.kt | 18 +++++++++--------- .../woopos/home/items/WooPosItemsViewModel.kt | 18 +++++++++--------- 3 files changed, 21 insertions(+), 21 deletions(-) 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 index 3c3a71766ebd..0f2a4a043d19 100644 --- 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 @@ -35,12 +35,12 @@ import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTyp @Composable fun WooPosCatalogSyncOverdueBanner( - bannerState: WooPosItemsViewModel.CatalogSyncBannerState, + state: WooPosItemsViewModel.CatalogSyncOverdueBannerState, modifier: Modifier = Modifier, onDismiss: () -> Unit ) { AnimatedVisibility( - visible = bannerState is WooPosItemsViewModel.CatalogSyncBannerState.OverdueSyncWarning, + visible = state is WooPosItemsViewModel.CatalogSyncOverdueBannerState.Visible, enter = fadeIn( animationSpec = tween(durationMillis = 180) ) + scaleIn( @@ -106,7 +106,7 @@ fun WooPosCatalogSyncOverdueBanner( fun WooPosRefreshCatalogBannerPreview() { WooPosTheme { WooPosCatalogSyncOverdueBanner( - bannerState = WooPosItemsViewModel.CatalogSyncBannerState.OverdueSyncWarning, + 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 85852f06cf68..a4e03c676c6b 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 @@ -65,7 +65,7 @@ fun WooPosItemsScreen( WooPosItemsScreen( modifier = modifier, itemsStateFlow = productsViewModel.viewState, - catalogSyncBannerStateFlow = productsViewModel.catalogSyncBannerState, + catalogSyncOverdueBannerStateFlow = productsViewModel.catalogSyncOverdueBannerState, productsViewState = productsViewState, couponsListState = couponsListState, catalogSyncState = catalogSyncState, @@ -79,7 +79,7 @@ fun WooPosItemsScreen( private fun WooPosItemsScreen( modifier: Modifier = Modifier, itemsStateFlow: StateFlow, - catalogSyncBannerStateFlow: StateFlow, + catalogSyncOverdueBannerStateFlow: StateFlow, productsViewState: LazyListState, couponsListState: LazyListState, catalogSyncState: CatalogSyncState, @@ -87,7 +87,7 @@ private fun WooPosItemsScreen( onUIEvent: (WooPosItemsUIEvent) -> Unit, ) { val state = itemsStateFlow.collectAsState() - val catalogSyncOverdueBannerState = catalogSyncBannerStateFlow.collectAsState() + val catalogSyncOverdueBannerState = catalogSyncOverdueBannerStateFlow.collectAsState() MainItemsList( modifier = modifier, @@ -125,7 +125,7 @@ private fun WooPosItemsScreen( private fun MainItemsList( modifier: Modifier, state: State, - bannerState: State, + bannerState: State, productsViewState: LazyListState, couponsListState: LazyListState, catalogSyncState: CatalogSyncState, @@ -159,7 +159,7 @@ private fun MainItemsList( ) WooPosCatalogSyncOverdueBanner( - bannerState = bannerState.value, + state = bannerState.value, onDismiss = onSyncWarningBannerDismissed ) @@ -324,12 +324,12 @@ fun WooPosItemsScreenSearchVisiblePreview(modifier: Modifier = Modifier) { tabs = tabs() ) ) - val bannerState = MutableStateFlow(WooPosItemsViewModel.CatalogSyncBannerState.OverdueSyncWarning) + val bannerState = MutableStateFlow(WooPosItemsViewModel.CatalogSyncOverdueBannerState.Visible) WooPosTheme { WooPosItemsScreen( modifier = modifier, itemsStateFlow = productState, - catalogSyncBannerStateFlow = bannerState, + catalogSyncOverdueBannerStateFlow = bannerState, productsViewState = rememberLazyListState(), couponsListState = rememberLazyListState(), catalogSyncState = CatalogSyncState.Idle, @@ -354,12 +354,12 @@ fun WooPosItemsScreenSearchHiddenPreview(modifier: Modifier = Modifier) { tabs = tabs() ) ) - val bannerState = MutableStateFlow(WooPosItemsViewModel.CatalogSyncBannerState.OverdueSyncWarning) + val bannerState = MutableStateFlow(WooPosItemsViewModel.CatalogSyncOverdueBannerState.Visible) WooPosTheme { WooPosItemsScreen( modifier = modifier, itemsStateFlow = productState, - catalogSyncBannerStateFlow = bannerState, + catalogSyncOverdueBannerStateFlow = bannerState, productsViewState = rememberLazyListState(), couponsListState = rememberLazyListState(), catalogSyncState = CatalogSyncState.Idle, 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 8bba55db477b..998000343b1a 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 @@ -48,8 +48,8 @@ class WooPosItemsViewModel @Inject constructor( initialValue = _viewState.value, ) - private val _catalogSyncBannerState = MutableStateFlow(CatalogSyncBannerState.Hidden) - val catalogSyncBannerState: StateFlow = _catalogSyncBannerState + private val _catalogSyncOverdueBannerState = MutableStateFlow(CatalogSyncOverdueBannerState.Hidden) + val catalogSyncOverdueBannerState: StateFlow = _catalogSyncOverdueBannerState init { listenUpEvents() @@ -68,9 +68,9 @@ class WooPosItemsViewModel @Inject constructor( private fun checkSyncStatusAndUpdateBanner() { viewModelScope.launch { val requirement = syncStatusChecker.checkSyncRequirement() - _catalogSyncBannerState.value = when (requirement) { - is WooPosFullSyncRequirement.Overdue -> CatalogSyncBannerState.OverdueSyncWarning - else -> CatalogSyncBannerState.Hidden + _catalogSyncOverdueBannerState.value = when (requirement) { + is WooPosFullSyncRequirement.Overdue -> CatalogSyncOverdueBannerState.Visible + else -> CatalogSyncOverdueBannerState.Hidden } } } @@ -96,7 +96,7 @@ class WooPosItemsViewModel @Inject constructor( is WooPosItemsUIEvent.AddCouponIconClicked -> createAndAddCoupon() WooPosItemsUIEvent.SyncOverdueBannerDismissed -> { - _catalogSyncBannerState.value = CatalogSyncBannerState.Hidden + _catalogSyncOverdueBannerState.value = CatalogSyncOverdueBannerState.Hidden } } } @@ -263,8 +263,8 @@ class WooPosItemsViewModel @Inject constructor( data class Coupon(override val id: Long, val couponCode: String) : ItemClickedData(id), Parcelable } - sealed class CatalogSyncBannerState { - data object Hidden : CatalogSyncBannerState() - data object OverdueSyncWarning : CatalogSyncBannerState() + sealed class CatalogSyncOverdueBannerState { + data object Hidden : CatalogSyncOverdueBannerState() + data object Visible : CatalogSyncOverdueBannerState() } } From c2db87ca7b65da0ba0e053875266c0d9f804934b Mon Sep 17 00:00:00 2001 From: samiuelson Date: Fri, 17 Oct 2025 18:22:05 +0200 Subject: [PATCH 45/46] Update padding --- .../ui/woopos/home/items/WooPosCatalogSyncOverdueBanner.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 0f2a4a043d19..f212fe123ea3 100644 --- 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 @@ -46,7 +46,7 @@ fun WooPosCatalogSyncOverdueBanner( ) + scaleIn( animationSpec = tween(durationMillis = 180) ), - modifier = modifier.padding(horizontal = WooPosSpacing.Medium.value) + modifier = modifier.padding(horizontal = WooPosSpacing.Small.value) ) { WooPosCard( modifier = Modifier.fillMaxWidth(), From 56bee469a4b2cf1893d0ea1b894af2e806d2c8cc Mon Sep 17 00:00:00 2001 From: samiuelson Date: Mon, 20 Oct 2025 07:59:30 +0200 Subject: [PATCH 46/46] Rename class --- .../localcatalog/WooPosPerformLocalCatalogInitialFullSync.kt | 4 ++-- .../android/ui/woopos/splash/WooPosSplashViewModel.kt | 2 +- .../android/ui/woopos/splash/WooPosSplashViewModelTest.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 index 7ee0c0e7b19e..ab7bb7fbfdee 100644 --- 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 @@ -14,7 +14,7 @@ class WooPosPerformLocalCatalogInitialFullSync @Inject constructor( when (requirement) { is WooPosFullSyncRequirement.NotRequired, is WooPosFullSyncRequirement.Overdue -> { - emit(WooPosLocalCatalogInitialFullSyncState.Ready) + emit(WooPosLocalCatalogInitialFullSyncState.NotRequired) } is WooPosFullSyncRequirement.BlockingRequired -> { performFullSync().collect { syncStatus -> @@ -39,7 +39,7 @@ class WooPosPerformLocalCatalogInitialFullSync @Inject constructor( } sealed class WooPosLocalCatalogInitialFullSyncState { - data object Ready : 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/WooPosSplashViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/splash/WooPosSplashViewModel.kt index c41ca8e535c1..7963bbd89d6e 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 @@ -65,7 +65,7 @@ class WooPosSplashViewModel @Inject constructor( startTime: Long ) = FlowCollector { syncState -> when (syncState) { - is WooPosLocalCatalogInitialFullSyncState.Ready -> { + is WooPosLocalCatalogInitialFullSyncState.NotRequired -> { _state.value = WooPosSplashState.Loaded trackPosLoaded(startTime) } 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 f82c15880d2b..d863efb85838 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 @@ -157,7 +157,7 @@ class WooPosSplashViewModelTest { } private fun createSut(): WooPosSplashViewModel { - whenever(performInitialFullSync()).thenReturn(flowOf(WooPosLocalCatalogInitialFullSyncState.Ready)) + whenever(performInitialFullSync()).thenReturn(flowOf(WooPosLocalCatalogInitialFullSyncState.NotRequired)) return WooPosSplashViewModel( productsDataSource,