From 3be1c9c10b8f9f170545ca01c5b1f9070913a6ec Mon Sep 17 00:00:00 2001 From: malinajirka Date: Tue, 7 Oct 2025 09:06:21 +0200 Subject: [PATCH 01/16] Add local catalog setting navigation --- .../android/ui/woopos/settings/WooPosSettingsState.kt | 8 ++++++++ .../settings/categories/WooPosSettingsCategoriesState.kt | 7 +++++++ .../settings/details/WooPosSettingsDetailPaneScreen.kt | 5 +++++ 3 files changed, 20 insertions(+) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/WooPosSettingsState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/WooPosSettingsState.kt index 4d21bd2c409e..11d0fcc963db 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/WooPosSettingsState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/WooPosSettingsState.kt @@ -49,6 +49,14 @@ sealed class WooPosSettingsDetailDestination { } } + sealed class LocalCatalog : WooPosSettingsDetailDestination() { + data object Overview : LocalCatalog() { + override val titleRes: Int = R.string.woopos_settings_local_catalog_category + override val parentDestination: WooPosSettingsDetailDestination? = null + override val childDestinations: List = emptyList() + } + } + sealed class Help : WooPosSettingsDetailDestination() { data object Overview : Help() { override val titleRes: Int = R.string.woopos_settings_help_category diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesState.kt index b878c5a9c51c..601743bcdbb5 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesState.kt @@ -4,6 +4,7 @@ import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Help import androidx.compose.material.icons.filled.Hardware +import androidx.compose.material.icons.filled.Inventory import androidx.compose.material.icons.filled.Store import androidx.compose.ui.graphics.vector.ImageVector import com.woocommerce.android.R @@ -22,6 +23,12 @@ enum class WooPosSettingsCategory( Icons.Default.Store, WooPosSettingsDetailDestination.Store.Overview ), + LOCAL_CATALOG( + R.string.woopos_settings_local_catalog_category, + R.string.woopos_settings_local_catalog_category_subtitle, + Icons.Default.Inventory, + WooPosSettingsDetailDestination.LocalCatalog.Overview + ), HARDWARE( R.string.woopos_settings_hardware_category, R.string.woopos_settings_hardware_category_subtitle, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/WooPosSettingsDetailPaneScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/WooPosSettingsDetailPaneScreen.kt index 0232818edef1..9262c358e9d1 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/WooPosSettingsDetailPaneScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/WooPosSettingsDetailPaneScreen.kt @@ -23,6 +23,7 @@ import com.woocommerce.android.ui.woopos.settings.details.hardware.WooPosHardwar import com.woocommerce.android.ui.woopos.settings.details.hardware.barcodescanner.WooPosSettingsHardwareBarcodeScannerScreen import com.woocommerce.android.ui.woopos.settings.details.hardware.cardreader.WooPosSettingsHardwareCardReaderScreen import com.woocommerce.android.ui.woopos.settings.details.help.WooPosHelpDetailScreen +import com.woocommerce.android.ui.woopos.settings.details.localcatalog.WooPosSettingsLocalCatalogScreen import com.woocommerce.android.ui.woopos.settings.details.store.WooPosSettingsStoreScreen @Composable @@ -93,6 +94,10 @@ fun WooPosSettingsDetailPaneScreen( WooPosSettingsStoreScreen() } + is WooPosSettingsDetailDestination.LocalCatalog.Overview -> { + WooPosSettingsLocalCatalogScreen() + } + is WooPosSettingsDetailDestination.Help.Overview -> { WooPosHelpDetailScreen(onShowProductInfoDialog = onShowProductInfoDialog) } From 5f81536015908de4bc608735dc335212edbe72b0 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Tue, 7 Oct 2025 09:08:54 +0200 Subject: [PATCH 02/16] tracking --- .../ui/woopos/settings/WooPosSettingsViewModel.kt | 2 ++ .../woopos/util/analytics/WooPosAnalyticsEvent.kt | 4 ++++ WooCommerce/src/main/res/values/strings.xml | 13 +++++++++++++ 3 files changed, 19 insertions(+) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/WooPosSettingsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/WooPosSettingsViewModel.kt index 559df6e31465..8027b4647bc3 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/WooPosSettingsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/WooPosSettingsViewModel.kt @@ -6,6 +6,7 @@ import com.woocommerce.android.ui.woopos.settings.categories.WooPosSettingsCateg import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.HardwareTapped import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.HelpTapped +import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.LocalCatalogTapped import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.SettingsClosed import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.SettingsOpened import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.StoreDetailsTapped @@ -38,6 +39,7 @@ class WooPosSettingsViewModel @Inject constructor( private fun trackCategorySelection(category: WooPosSettingsCategory) { val event = when (category) { WooPosSettingsCategory.STORE -> StoreDetailsTapped + WooPosSettingsCategory.LOCAL_CATALOG -> LocalCatalogTapped WooPosSettingsCategory.HARDWARE -> HardwareTapped WooPosSettingsCategory.HELP -> HelpTapped } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/analytics/WooPosAnalyticsEvent.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/analytics/WooPosAnalyticsEvent.kt index 70bcc65e5fd3..83534602b730 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/analytics/WooPosAnalyticsEvent.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/analytics/WooPosAnalyticsEvent.kt @@ -344,6 +344,10 @@ sealed class WooPosAnalyticsEvent : IAnalyticsEvent { override val name: String = "store_details_tapped" } + data object LocalCatalogTapped : Event() { + override val name: String = "local_catalog_tapped" + } + data object HardwareTapped : Event() { override val name: String = "hardware_tapped" } diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index ce2691a66c2e..5602709ccb3c 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -3645,6 +3645,19 @@ Configure barcode scanner settings Card Readers Manage card reader connections + Catalog + Manage catalog settings + Catalog Status + Catalog Size + Last Update + Last Full Update + Manage Data Usage + Allow full update on cellular data + Download catalog updates using mobile data + Manual Catalog Update + Use this refresh only when something seems off - POS keeps data current automatically.x + Refresh Catalog + Refreshing… Barcode Scanner Settings Scanner Setup Configure and test your barcode scanner From cfa79680b7afb3160d444779dc61312506fe9616 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Tue, 7 Oct 2025 09:15:33 +0200 Subject: [PATCH 03/16] Add dummy catalog VM --- .../WooPosSettingsLocalCatalogState.kt | 14 ++++ .../WooPosSettingsLocalCatalogViewModel.kt | 77 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogState.kt create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogState.kt new file mode 100644 index 000000000000..de9399a6f1f5 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogState.kt @@ -0,0 +1,14 @@ +package com.woocommerce.android.ui.woopos.settings.details.localcatalog + +data class WooPosSettingsLocalCatalogState( + val catalogStatus: CatalogStatus? = null, + val allowCellularDataUpdate: Boolean = false, + val isLoading: Boolean = false, + val isRefreshing: Boolean = false +) + +data class CatalogStatus( + val catalogSize: String, + val lastUpdate: String, + val lastFullUpdate: String +) \ No newline at end of file diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt new file mode 100644 index 000000000000..04a139c55b52 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt @@ -0,0 +1,77 @@ +package com.woocommerce.android.ui.woopos.settings.details.localcatalog + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class WooPosSettingsLocalCatalogViewModel @Inject constructor() : ViewModel() { + private val _state = MutableStateFlow(WooPosSettingsLocalCatalogState()) + val state: StateFlow = _state.asStateFlow() + + init { + loadCatalogStatus() + } + + private fun loadCatalogStatus() { + viewModelScope.launch { + _state.update { it.copy(isLoading = true) } + + // TODO: Replace with actual repository call to fetch catalog status + delay(1000) // Simulate network call + + val mockStatus = CatalogStatus( + catalogSize = "8.3 MB", + lastUpdate = "5 minutes ago", + lastFullUpdate = "Today at 9:15 AM" + ) + + _state.update { + it.copy( + catalogStatus = mockStatus, + isLoading = false + ) + } + } + } + + fun toggleCellularDataUpdate() { + viewModelScope.launch { + _state.update { + it.copy(allowCellularDataUpdate = !it.allowCellularDataUpdate) + } + + // TODO: Save preference to shared preferences or data store + } + } + + fun refreshCatalog() { + viewModelScope.launch { + _state.update { it.copy(isRefreshing = true) } + + // TODO: Trigger actual catalog refresh through repository + delay(3000) // Simulate refresh operation + + // Update with new status after refresh + val updatedStatus = CatalogStatus( + catalogSize = "8.5 MB", + lastUpdate = "Just now", + lastFullUpdate = "Just now" + ) + + _state.update { + it.copy( + catalogStatus = updatedStatus, + isRefreshing = false + ) + } + } + } +} \ No newline at end of file From 5e6a2774fb72a2abf89c0acbb4950bcfc4d163c5 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Tue, 7 Oct 2025 11:55:33 +0200 Subject: [PATCH 04/16] Add initial Local Catalog settings screen --- .../WooPosSettingsLocalCatalogScreen.kt | 290 ++++++++++++++++++ WooCommerce/src/main/res/values/strings.xml | 1 - 2 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt new file mode 100644 index 000000000000..d450e9caad86 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt @@ -0,0 +1,290 @@ +package com.woocommerce.android.ui.woopos.settings.details.localcatalog + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.WooPosButton +import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosButtonState +import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosShimmerBox +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 WooPosSettingsLocalCatalogScreen( + modifier: Modifier = Modifier, + viewModel: WooPosSettingsLocalCatalogViewModel = hiltViewModel() +) { + val state by viewModel.state.collectAsState() + + WooPosSettingsLocalCatalogScreen( + state = state, + onToggleCellularData = viewModel::toggleCellularDataUpdate, + onRefreshCatalog = viewModel::refreshCatalog, + modifier = modifier + ) +} + +@Composable +private fun WooPosSettingsLocalCatalogScreen( + state: WooPosSettingsLocalCatalogState, + onToggleCellularData: () -> Unit, + onRefreshCatalog: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(WooPosSpacing.Medium.value) + ) { + CatalogStatusSection( + catalogStatus = state.catalogStatus, + isLoading = state.isLoading + ) + + Spacer(modifier = Modifier.height(WooPosSpacing.Large.value)) + + SettingsSection( + allowCellularDataUpdate = state.allowCellularDataUpdate, + onToggleCellularData = onToggleCellularData, + isLoading = state.isLoading + ) + + Spacer(modifier = Modifier.height(WooPosSpacing.Large.value)) + + RefreshSection( + onRefreshCatalog = onRefreshCatalog, + isRefreshing = state.isRefreshing + ) + } +} + +@Composable +private fun CatalogStatusSection( + catalogStatus: CatalogStatus?, + isLoading: Boolean +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(WooPosCornerRadius.Large.value)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(WooPosSpacing.Medium.value) + ) { + SectionTitle(stringResource(R.string.woopos_settings_local_catalog_status)) + + Spacer(modifier = Modifier.height(WooPosSpacing.Medium.value)) + + if (isLoading || catalogStatus == null) { + StatusRow( + label = stringResource(R.string.woopos_settings_local_catalog_size), + value = null, + isLoading = true + ) + StatusRow( + label = stringResource(R.string.woopos_settings_local_catalog_last_update), + value = null, + isLoading = true + ) + StatusRow( + label = stringResource(R.string.woopos_settings_local_catalog_last_full_update), + value = null, + isLoading = true + ) + } else { + StatusRow( + label = stringResource(R.string.woopos_settings_local_catalog_size), + value = catalogStatus.catalogSize, + isLoading = false + ) + StatusRow( + label = stringResource(R.string.woopos_settings_local_catalog_last_update), + value = catalogStatus.lastUpdate, + isLoading = false + ) + StatusRow( + label = stringResource(R.string.woopos_settings_local_catalog_last_full_update), + value = catalogStatus.lastFullUpdate, + isLoading = false + ) + } + } +} + +@Composable +private fun SettingsSection( + allowCellularDataUpdate: Boolean, + onToggleCellularData: () -> Unit, + isLoading: Boolean +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(WooPosCornerRadius.Large.value)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(WooPosSpacing.Medium.value) + ) { + SectionTitle(stringResource(R.string.woopos_settings_local_catalog_settings)) + + Spacer(modifier = Modifier.height(WooPosSpacing.Medium.value)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + WooPosText( + text = stringResource(R.string.woopos_settings_local_catalog_cellular_data), + style = WooPosTypography.BodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + WooPosText( + text = stringResource(R.string.woopos_settings_local_catalog_cellular_data_subtitle), + style = WooPosTypography.BodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = WooPosSpacing.XSmall.value) + ) + } + + Switch( + checked = allowCellularDataUpdate, + onCheckedChange = { onToggleCellularData() }, + enabled = !isLoading, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + ) + ) + } + } +} + +@Composable +private fun RefreshSection( + onRefreshCatalog: () -> Unit, + isRefreshing: Boolean +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(WooPosCornerRadius.Large.value)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(WooPosSpacing.Medium.value) + ) { + SectionTitle(stringResource(R.string.woopos_settings_local_catalog_actions)) + + Spacer(modifier = Modifier.height(WooPosSpacing.Medium.value)) + + WooPosText( + text = stringResource(R.string.woopos_settings_local_catalog_refresh_description), + style = WooPosTypography.BodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = WooPosSpacing.Medium.value) + ) + + WooPosButton( + modifier = Modifier.fillMaxWidth(), + onClick = onRefreshCatalog, + state = if (isRefreshing) WooPosButtonState.LOADING else WooPosButtonState.ENABLED, + text = stringResource(R.string.woopos_settings_local_catalog_refresh_button) + ) + } +} + +@Composable +private fun StatusRow( + label: String, + value: String?, + isLoading: Boolean +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = WooPosSpacing.Small.value), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + WooPosText( + text = label, + style = WooPosTypography.BodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (isLoading) { + WooPosShimmerBox( + modifier = Modifier + .width(100.dp) + .height(20.dp), + ) + } else { + WooPosText( + text = value ?: "-", + style = WooPosTypography.BodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Composable +private fun SectionTitle(title: String) { + WooPosText( + text = title, + style = WooPosTypography.BodyXLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) +} + +@WooPosPreview +@Composable +fun WooPosSettingsLocalCatalogScreenPreview() { + WooPosTheme { + WooPosSettingsLocalCatalogScreen( + state = WooPosSettingsLocalCatalogState( + catalogStatus = CatalogStatus( + catalogSize = "12.5 MB", + lastUpdate = "2 hours ago", + lastFullUpdate = "Yesterday at 3:45 PM" + ), + allowCellularDataUpdate = true, + isLoading = false, + isRefreshing = false + ), + onToggleCellularData = {}, + onRefreshCatalog = {} + ) + } +} diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 5602709ccb3c..2513cf54a898 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -3657,7 +3657,6 @@ Manual Catalog Update Use this refresh only when something seems off - POS keeps data current automatically.x Refresh Catalog - Refreshing… Barcode Scanner Settings Scanner Setup Configure and test your barcode scanner From adeec984bd8e8ed4c330578587e7c5c5c8aecffd Mon Sep 17 00:00:00 2001 From: malinajirka Date: Tue, 7 Oct 2025 12:32:46 +0200 Subject: [PATCH 05/16] Introduce date formatter --- .../woopos/util/format/WooPosDateFormatter.kt | 80 +++++++++++++++++++ WooCommerce/src/main/res/values/strings.xml | 6 ++ 2 files changed, 86 insertions(+) create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/format/WooPosDateFormatter.kt diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/format/WooPosDateFormatter.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/format/WooPosDateFormatter.kt new file mode 100644 index 000000000000..83c145dc165c --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/format/WooPosDateFormatter.kt @@ -0,0 +1,80 @@ +package com.woocommerce.android.ui.woopos.util.format + +import android.content.Context +import com.woocommerce.android.R +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject + +class WooPosDateFormatter @Inject constructor( + @ApplicationContext private val context: Context +) { + + private val timeFormatter = DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault()) + private val dateTimeThisYearFormatter = DateTimeFormatter.ofPattern("MMM d 'at' h:mm a", Locale.getDefault()) + private val dateTimeWithYearFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy 'at' h:mm a", Locale.getDefault()) + private val systemZone = ZoneId.systemDefault() + + /** + * Formats the older of two timestamps (products and variations) into a user-friendly string. + * Uses the older timestamp to represent when the catalog was fully synced. + * Returns "Never" if both timestamps are null. + */ + fun formatCatalogLastUpdate(productsTimestamp: Long?, variationsTimestamp: Long?): String { + val lastSyncTimestamp = when { + productsTimestamp == null && variationsTimestamp == null -> null + productsTimestamp == null -> variationsTimestamp + variationsTimestamp == null -> productsTimestamp + else -> minOf(productsTimestamp, variationsTimestamp) + } + + return lastSyncTimestamp?.let { + formatLastUpdateTimestamp(it) + } ?: context.getString(R.string.woopos_date_never) + } + + /** + * Formats a timestamp into a user-friendly string representation. + * Shows: + * - "Just now" for very recent updates (< 1 minute) + * - "Today at HH:mm" for today's updates + * - "Yesterday at HH:mm" for yesterday's updates + * - "MMM d at HH:mm" for dates within this year + * - "MMM d, yyyy at HH:mm" for older dates + */ + private fun formatLastUpdateTimestamp(timestamp: Long): String { + val instant = Instant.ofEpochMilli(timestamp) + val now = Instant.now() + val duration = Duration.between(instant, now) + + if (duration.toMinutes() < 1) { + return context.getString(R.string.woopos_date_just_now) + } + + val dateTime = ZonedDateTime.ofInstant(instant, systemZone) + val nowDateTime = ZonedDateTime.ofInstant(now, systemZone) + val date = dateTime.toLocalDate() + val today = nowDateTime.toLocalDate() + val formattedTime = dateTime.format(timeFormatter) + + return when { + date == today -> { + context.getString(R.string.woopos_date_today_at, formattedTime) + } + date == today.minusDays(1) -> { + context.getString(R.string.woopos_date_yesterday_at, formattedTime) + } + date.year == today.year -> { + dateTime.format(dateTimeThisYearFormatter) + } + else -> { + dateTime.format(dateTimeWithYearFormatter) + } + } + } +} diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 2513cf54a898..450bc46f677b 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -3657,6 +3657,12 @@ Manual Catalog Update Use this refresh only when something seems off - POS keeps data current automatically.x Refresh Catalog + + Never + Just now + Today at %s + Yesterday at %s + Barcode Scanner Settings Scanner Setup Configure and test your barcode scanner From 7674a695b785a7e137421594b334682e37c780fd Mon Sep 17 00:00:00 2001 From: malinajirka Date: Tue, 7 Oct 2025 12:33:29 +0200 Subject: [PATCH 06/16] Use actual last update dates --- .../WooPosSettingsLocalCatalogViewModel.kt | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt index 04a139c55b52..da37a0b62609 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt @@ -2,8 +2,12 @@ package com.woocommerce.android.ui.woopos.settings.details.localcatalog import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.woocommerce.android.tools.SelectedSite +import com.woocommerce.android.ui.woopos.localcatalog.PosLocalCatalogSyncResult +import com.woocommerce.android.ui.woopos.localcatalog.WooPosLocalCatalogSyncRepository +import com.woocommerce.android.ui.woopos.util.datastore.WooPosSyncTimestampManager +import com.woocommerce.android.ui.woopos.util.format.WooPosDateFormatter import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -12,7 +16,12 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class WooPosSettingsLocalCatalogViewModel @Inject constructor() : ViewModel() { +class WooPosSettingsLocalCatalogViewModel @Inject constructor( + private val syncTimestampManager: WooPosSyncTimestampManager, + private val localCatalogSyncRepository: WooPosLocalCatalogSyncRepository, + private val selectedSite: SelectedSite, + private val dateFormatter: WooPosDateFormatter, +) : ViewModel() { private val _state = MutableStateFlow(WooPosSettingsLocalCatalogState()) val state: StateFlow = _state.asStateFlow() @@ -23,19 +32,26 @@ class WooPosSettingsLocalCatalogViewModel @Inject constructor() : ViewModel() { private fun loadCatalogStatus() { viewModelScope.launch { _state.update { it.copy(isLoading = true) } - - // TODO: Replace with actual repository call to fetch catalog status - delay(1000) // Simulate network call - - val mockStatus = CatalogStatus( - catalogSize = "8.3 MB", - lastUpdate = "5 minutes ago", - lastFullUpdate = "Today at 9:15 AM" + + // Get timestamps for products and variations + val productsTimestamp = syncTimestampManager.getProductsLastSyncTimestamp() + val variationsTimestamp = syncTimestampManager.getVariationsLastSyncTimestamp() + + // Format timestamps for display + val formattedTimestamp = dateFormatter.formatCatalogLastUpdate( + productsTimestamp, + variationsTimestamp ) - + + val catalogStatus = CatalogStatus( + catalogSize = "8.3 MB", // TODO: Replace with actual catalog size + lastUpdate = formattedTimestamp, + lastFullUpdate = formattedTimestamp // TODO: Replace with full sync timestamp + ) + _state.update { it.copy( - catalogStatus = mockStatus, + catalogStatus = catalogStatus, isLoading = false ) } @@ -47,7 +63,7 @@ class WooPosSettingsLocalCatalogViewModel @Inject constructor() : ViewModel() { _state.update { it.copy(allowCellularDataUpdate = !it.allowCellularDataUpdate) } - + // TODO: Save preference to shared preferences or data store } } @@ -74,4 +90,4 @@ class WooPosSettingsLocalCatalogViewModel @Inject constructor() : ViewModel() { } } } -} \ No newline at end of file +} From 079ada96c9e5b07b9363c0fd762b5778fcb8870e Mon Sep 17 00:00:00 2001 From: malinajirka Date: Tue, 7 Oct 2025 12:40:19 +0200 Subject: [PATCH 07/16] Run full catalog sync on manual trigger --- .../WooPosSettingsLocalCatalogScreen.kt | 2 +- .../WooPosSettingsLocalCatalogViewModel.kt | 30 ++++++++----------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt index d450e9caad86..3df73dde537d 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt @@ -47,7 +47,7 @@ fun WooPosSettingsLocalCatalogScreen( WooPosSettingsLocalCatalogScreen( state = state, onToggleCellularData = viewModel::toggleCellularDataUpdate, - onRefreshCatalog = viewModel::refreshCatalog, + onRefreshCatalog = viewModel::runFullCatalogSync, modifier = modifier ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt index da37a0b62609..57f1fe611d18 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt @@ -68,26 +68,22 @@ class WooPosSettingsLocalCatalogViewModel @Inject constructor( } } - fun refreshCatalog() { + fun runFullCatalogSync() { viewModelScope.launch { _state.update { it.copy(isRefreshing = true) } - - // TODO: Trigger actual catalog refresh through repository - delay(3000) // Simulate refresh operation - - // Update with new status after refresh - val updatedStatus = CatalogStatus( - catalogSize = "8.5 MB", - lastUpdate = "Just now", - lastFullUpdate = "Just now" - ) - - _state.update { - it.copy( - catalogStatus = updatedStatus, - isRefreshing = false - ) + + val result = localCatalogSyncRepository.syncLocalCatalogFull(selectedSite.get()) + + when (result) { + is PosLocalCatalogSyncResult.Success -> { + loadCatalogStatus() + } + is PosLocalCatalogSyncResult.Failure -> { + // TODO: Handle errors + } } + + _state.update { it.copy(isRefreshing = false) } } } } From 0e2b6d9d65229ed82d0cac993bec518812a403db Mon Sep 17 00:00:00 2001 From: malinajirka Date: Tue, 7 Oct 2025 12:44:35 +0200 Subject: [PATCH 08/16] Show local catalog settings only with FF --- .../WooPosSettingsCategoriesViewModel.kt | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesViewModel.kt index 8f82c4203a83..e5f9ad29f35a 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesViewModel.kt @@ -1,14 +1,30 @@ package com.woocommerce.android.ui.woopos.settings.categories +import android.content.Context import androidx.lifecycle.ViewModel +import com.woocommerce.android.util.FeatureFlag import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject @HiltViewModel -class WooPosSettingsCategoriesViewModel @Inject constructor() : ViewModel() { - private val _state = MutableStateFlow(WooPosSettingsCategoriesState()) +class WooPosSettingsCategoriesViewModel @Inject constructor( + @ApplicationContext private val context: Context +) : ViewModel() { + private val _state = MutableStateFlow(createInitialState()) val state: StateFlow = _state.asStateFlow() + + private fun createInitialState(): WooPosSettingsCategoriesState { + val allCategories = WooPosSettingsCategory.entries + val visibleCategories = if (FeatureFlag.WOO_POS_LOCAL_CATALOG_M1.isEnabled(context)) { + allCategories + } else { + allCategories.filter { it != WooPosSettingsCategory.LOCAL_CATALOG } + } + + return WooPosSettingsCategoriesState(categories = visibleCategories) + } } From a88e91b5578354bdd340f1be295bc888ccbb8133 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Tue, 7 Oct 2025 12:47:43 +0200 Subject: [PATCH 09/16] Replace todos with tbd --- .../WooPosSettingsLocalCatalogViewModel.kt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt index 57f1fe611d18..34ebd7ad5c28 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt @@ -33,20 +33,18 @@ class WooPosSettingsLocalCatalogViewModel @Inject constructor( viewModelScope.launch { _state.update { it.copy(isLoading = true) } - // Get timestamps for products and variations val productsTimestamp = syncTimestampManager.getProductsLastSyncTimestamp() val variationsTimestamp = syncTimestampManager.getVariationsLastSyncTimestamp() - // Format timestamps for display val formattedTimestamp = dateFormatter.formatCatalogLastUpdate( productsTimestamp, variationsTimestamp ) val catalogStatus = CatalogStatus( - catalogSize = "8.3 MB", // TODO: Replace with actual catalog size + catalogSize = "8.3 MB", // TBD local catalog: Replace with actual catalog size lastUpdate = formattedTimestamp, - lastFullUpdate = formattedTimestamp // TODO: Replace with full sync timestamp + lastFullUpdate = formattedTimestamp // TBD local catalog: Replace with full sync timestamp ) _state.update { @@ -63,8 +61,7 @@ class WooPosSettingsLocalCatalogViewModel @Inject constructor( _state.update { it.copy(allowCellularDataUpdate = !it.allowCellularDataUpdate) } - - // TODO: Save preference to shared preferences or data store + // TBD local catalog: Save preference to shared preferences or data store } } @@ -79,7 +76,7 @@ class WooPosSettingsLocalCatalogViewModel @Inject constructor( loadCatalogStatus() } is PosLocalCatalogSyncResult.Failure -> { - // TODO: Handle errors + // TBD local catalog: Handle errors } } From 72e9b4b49c7ee774929857f6579cd015dd6155b8 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Tue, 7 Oct 2025 12:55:56 +0200 Subject: [PATCH 10/16] Fix detekt --- .../settings/categories/WooPosSettingsCategoriesViewModel.kt | 2 -- .../details/localcatalog/WooPosSettingsLocalCatalogState.kt | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesViewModel.kt index e5f9ad29f35a..35bbfb637ab6 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/categories/WooPosSettingsCategoriesViewModel.kt @@ -16,7 +16,6 @@ class WooPosSettingsCategoriesViewModel @Inject constructor( ) : ViewModel() { private val _state = MutableStateFlow(createInitialState()) val state: StateFlow = _state.asStateFlow() - private fun createInitialState(): WooPosSettingsCategoriesState { val allCategories = WooPosSettingsCategory.entries val visibleCategories = if (FeatureFlag.WOO_POS_LOCAL_CATALOG_M1.isEnabled(context)) { @@ -24,7 +23,6 @@ class WooPosSettingsCategoriesViewModel @Inject constructor( } else { allCategories.filter { it != WooPosSettingsCategory.LOCAL_CATALOG } } - return WooPosSettingsCategoriesState(categories = visibleCategories) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogState.kt index de9399a6f1f5..8872a3fc4ac4 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogState.kt @@ -11,4 +11,4 @@ data class CatalogStatus( val catalogSize: String, val lastUpdate: String, val lastFullUpdate: String -) \ No newline at end of file +) From 0bee95f48401884fd37c6c213b5b2feb355955c4 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Tue, 7 Oct 2025 13:32:00 +0200 Subject: [PATCH 11/16] Make WooPosDateFormatter testable --- .../ui/woopos/util/format/WooPosDateFormatter.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/format/WooPosDateFormatter.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/format/WooPosDateFormatter.kt index 83c145dc165c..0d8db143aea0 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/format/WooPosDateFormatter.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/util/format/WooPosDateFormatter.kt @@ -3,22 +3,22 @@ package com.woocommerce.android.ui.woopos.util.format import android.content.Context import com.woocommerce.android.R import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.Clock import java.time.Duration import java.time.Instant -import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.util.Locale import javax.inject.Inject class WooPosDateFormatter @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val clock: Clock ) { private val timeFormatter = DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault()) private val dateTimeThisYearFormatter = DateTimeFormatter.ofPattern("MMM d 'at' h:mm a", Locale.getDefault()) private val dateTimeWithYearFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy 'at' h:mm a", Locale.getDefault()) - private val systemZone = ZoneId.systemDefault() /** * Formats the older of two timestamps (products and variations) into a user-friendly string. @@ -49,15 +49,15 @@ class WooPosDateFormatter @Inject constructor( */ private fun formatLastUpdateTimestamp(timestamp: Long): String { val instant = Instant.ofEpochMilli(timestamp) - val now = Instant.now() + val now = clock.instant() val duration = Duration.between(instant, now) if (duration.toMinutes() < 1) { return context.getString(R.string.woopos_date_just_now) } - val dateTime = ZonedDateTime.ofInstant(instant, systemZone) - val nowDateTime = ZonedDateTime.ofInstant(now, systemZone) + val dateTime = ZonedDateTime.ofInstant(instant, clock.zone) + val nowDateTime = ZonedDateTime.ofInstant(now, clock.zone) val date = dateTime.toLocalDate() val today = nowDateTime.toLocalDate() val formattedTime = dateTime.format(timeFormatter) From 9c461a5839518eaede04091d5a61c6e06e78fff7 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Tue, 7 Oct 2025 13:32:09 +0200 Subject: [PATCH 12/16] Add tests for WooPosDateFormatter --- .../util/format/WooPosDateFormatterTest.kt | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/util/format/WooPosDateFormatterTest.kt diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/util/format/WooPosDateFormatterTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/util/format/WooPosDateFormatterTest.kt new file mode 100644 index 000000000000..0a415c65a1f4 --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/util/format/WooPosDateFormatterTest.kt @@ -0,0 +1,198 @@ +package com.woocommerce.android.ui.woopos.util.format + +import android.content.Context +import com.woocommerce.android.R +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime + +class WooPosDateFormatterTest { + private val context: Context = mock() + private val fixedInstant = Instant.parse("2024-01-15T15:30:00Z") + private val fixedClock = Clock.fixed(fixedInstant, ZoneId.of("UTC")) + private lateinit var formatter: WooPosDateFormatter + + @Before + fun setup() { + formatter = WooPosDateFormatter(context, fixedClock) + + whenever(context.getString(R.string.woopos_date_never)).thenReturn("Never") + whenever(context.getString(R.string.woopos_date_just_now)).thenReturn("Just now") + whenever(context.getString(eq(R.string.woopos_date_today_at), any())).thenAnswer { invocation -> + "Today at ${invocation.arguments[1]}" + } + whenever(context.getString(eq(R.string.woopos_date_yesterday_at), any())).thenAnswer { invocation -> + "Yesterday at ${invocation.arguments[1]}" + } + } + + @Test + fun `when both timestamps are null, then formatCatalogLastUpdate returns Never`() { + // GIVEN + val productsTimestamp: Long? = null + val variationsTimestamp: Long? = null + + // WHEN + val result = formatter.formatCatalogLastUpdate(productsTimestamp, variationsTimestamp) + + // THEN + assertThat(result).isEqualTo("Never") + } + + @Test + fun `when variations timestamp is null, then formatCatalogLastUpdate returns formatted products timestamp`() { + // GIVEN + val productsTimestamp = fixedInstant.minusSeconds(30).toEpochMilli() + val variationsTimestamp: Long? = null + + // WHEN + val result = formatter.formatCatalogLastUpdate(productsTimestamp, variationsTimestamp) + + // THEN + assertThat(result).isEqualTo("Just now") + } + + @Test + fun `when products timestamp is null, then formatCatalogLastUpdate returns formatted variations timestamp`() { + // GIVEN + val productsTimestamp: Long? = null + val variationsTimestamp = fixedInstant.minusSeconds(30).toEpochMilli() + + // WHEN + val result = formatter.formatCatalogLastUpdate(productsTimestamp, variationsTimestamp) + + // THEN + assertThat(result).isEqualTo("Just now") + } + + @Test + fun `when both timestamps are present, then formatCatalogLastUpdate returns older timestamp`() { + // GIVEN + val productsTimestamp = fixedInstant.minus(Duration.ofMinutes(2)).toEpochMilli() + val variationsTimestamp = fixedInstant.minusSeconds(30).toEpochMilli() + + // WHEN + val result = formatter.formatCatalogLastUpdate(productsTimestamp, variationsTimestamp) + + // THEN + assertThat(result).isEqualTo("Today at 3:28 PM") + } + + @Test + fun `when timestamp is very recent, then formatLastUpdateTimestamp returns Just now`() { + // GIVEN + val timestamp = fixedInstant.minusSeconds(30).toEpochMilli() + + // WHEN + val result = formatter.formatCatalogLastUpdate(timestamp, null) + + // THEN + assertThat(result).isEqualTo("Just now") + } + + @Test + fun `when timestamp is from today, then formatLastUpdateTimestamp returns Today at time`() { + // GIVEN + val now = ZonedDateTime.ofInstant(fixedInstant, fixedClock.zone) + val earlierToday = now.minusHours(3) + val timestamp = earlierToday.toInstant().toEpochMilli() + + // WHEN + val result = formatter.formatCatalogLastUpdate(timestamp, null) + + // THEN + assertThat(result).isEqualTo("Today at 12:30 PM") + } + + @Test + fun `when timestamp is from yesterday, then formatLastUpdateTimestamp returns Yesterday at time`() { + // GIVEN + val now = ZonedDateTime.ofInstant(fixedInstant, fixedClock.zone) + val yesterday = now.minusDays(1) + val timestamp = yesterday.toInstant().toEpochMilli() + + // WHEN + val result = formatter.formatCatalogLastUpdate(timestamp, null) + + // THEN + assertThat(result).isEqualTo("Yesterday at 3:30 PM") + } + + @Test + fun `when timestamp is from this year, then formatLastUpdateTimestamp returns formatted date`() { + // GIVEN + val now = ZonedDateTime.ofInstant(fixedInstant, fixedClock.zone) + val lastWeek = now.minusWeeks(1) + val timestamp = lastWeek.toInstant().toEpochMilli() + + // WHEN + val result = formatter.formatCatalogLastUpdate(timestamp, null) + + // THEN + assertThat(result).isEqualTo("Jan 8 at 3:30 PM") + } + + @Test + fun `when timestamp is from previous year, then formatLastUpdateTimestamp returns formatted date with year`() { + // GIVEN + val now = ZonedDateTime.ofInstant(fixedInstant, fixedClock.zone) + val lastYear = now.minusYears(1) + val timestamp = lastYear.toInstant().toEpochMilli() + + // WHEN + val result = formatter.formatCatalogLastUpdate(timestamp, null) + + // THEN + assertThat(result).isEqualTo("Jan 15, 2023 at 3:30 PM") + } + + @Test + fun `when two timestamps are provided, then formatCatalogLastUpdate selects minimum timestamp`() { + // GIVEN + val olderTimestamp = 1000L + val newerTimestamp = 2000L + + // WHEN + val result1 = formatter.formatCatalogLastUpdate(olderTimestamp, newerTimestamp) + val result2 = formatter.formatCatalogLastUpdate(newerTimestamp, olderTimestamp) + + // THEN + assertThat(result1).isEqualTo(result2) + } + + @Test + fun `when timestamp is exactly 1 minute ago, then formatLastUpdateTimestamp handles edge case correctly`() { + // GIVEN + val timestamp = fixedInstant.minusSeconds(60).toEpochMilli() + + // WHEN + val result = formatter.formatCatalogLastUpdate(timestamp, null) + + // THEN + assertThat(result).isEqualTo("Today at 3:29 PM") + } + + @Test + fun `when timestamp crosses midnight boundary, then formatLastUpdateTimestamp handles correctly`() { + // GIVEN + val now = ZonedDateTime.ofInstant(fixedInstant, fixedClock.zone) + val todayMidnight = now.toLocalDate().atStartOfDay(fixedClock.zone) + val justBeforeMidnight = todayMidnight.minusMinutes(1) + val timestamp = justBeforeMidnight.toInstant().toEpochMilli() + + // WHEN + val result = formatter.formatCatalogLastUpdate(timestamp, null) + + // THEN + assertThat(result).isEqualTo("Yesterday at 11:59 PM") + } +} From 6be1314365bcbb2b5b27b1e4ca1b85a1c6e041be Mon Sep 17 00:00:00 2001 From: malinajirka Date: Tue, 7 Oct 2025 18:53:02 +0200 Subject: [PATCH 13/16] Fix strings --- WooCommerce/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 450bc46f677b..b0043f3a1164 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -3655,7 +3655,7 @@ Allow full update on cellular data Download catalog updates using mobile data Manual Catalog Update - Use this refresh only when something seems off - POS keeps data current automatically.x + Use this refresh only when something seems off - POS keeps data current automatically. Refresh Catalog Never From 541fce315a37258b9fc7ea2ddaa367ce4338602f Mon Sep 17 00:00:00 2001 From: malinajirka Date: Thu, 9 Oct 2025 07:27:20 +0200 Subject: [PATCH 14/16] Update PosSettingsLocalCatalog state --- .../WooPosSettingsLocalCatalogScreen.kt | 116 +++++++++++------- .../WooPosSettingsLocalCatalogState.kt | 21 ++-- .../WooPosSettingsLocalCatalogViewModel.kt | 18 +-- 3 files changed, 95 insertions(+), 60 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt index 3df73dde537d..fd731b067d92 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt @@ -66,8 +66,7 @@ private fun WooPosSettingsLocalCatalogScreen( .padding(WooPosSpacing.Medium.value) ) { CatalogStatusSection( - catalogStatus = state.catalogStatus, - isLoading = state.isLoading + catalogStatus = state.catalogStatus ) Spacer(modifier = Modifier.height(WooPosSpacing.Large.value)) @@ -75,22 +74,21 @@ private fun WooPosSettingsLocalCatalogScreen( SettingsSection( allowCellularDataUpdate = state.allowCellularDataUpdate, onToggleCellularData = onToggleCellularData, - isLoading = state.isLoading + isLoading = state.catalogStatus is WooPosSettingsLocalCatalogState.CatalogStatus.LoadingStatus ) Spacer(modifier = Modifier.height(WooPosSpacing.Large.value)) RefreshSection( onRefreshCatalog = onRefreshCatalog, - isRefreshing = state.isRefreshing + isRefreshing = state.catalogStatus is WooPosSettingsLocalCatalogState.CatalogStatus.RefreshingCatalog ) } } @Composable private fun CatalogStatusSection( - catalogStatus: CatalogStatus?, - isLoading: Boolean + catalogStatus: WooPosSettingsLocalCatalogState.CatalogStatus ) { Column( modifier = Modifier @@ -103,38 +101,42 @@ private fun CatalogStatusSection( Spacer(modifier = Modifier.height(WooPosSpacing.Medium.value)) - if (isLoading || catalogStatus == null) { - StatusRow( - label = stringResource(R.string.woopos_settings_local_catalog_size), - value = null, - isLoading = true - ) - StatusRow( - label = stringResource(R.string.woopos_settings_local_catalog_last_update), - value = null, - isLoading = true - ) - StatusRow( - label = stringResource(R.string.woopos_settings_local_catalog_last_full_update), - value = null, - isLoading = true - ) - } else { - StatusRow( - label = stringResource(R.string.woopos_settings_local_catalog_size), - value = catalogStatus.catalogSize, - isLoading = false - ) - StatusRow( - label = stringResource(R.string.woopos_settings_local_catalog_last_update), - value = catalogStatus.lastUpdate, - isLoading = false - ) - StatusRow( - label = stringResource(R.string.woopos_settings_local_catalog_last_full_update), - value = catalogStatus.lastFullUpdate, - isLoading = false - ) + when (catalogStatus) { + is WooPosSettingsLocalCatalogState.CatalogStatus.Available -> { + StatusRow( + label = stringResource(R.string.woopos_settings_local_catalog_size), + value = catalogStatus.catalogSize, + isLoading = false + ) + StatusRow( + label = stringResource(R.string.woopos_settings_local_catalog_last_update), + value = catalogStatus.lastUpdate, + isLoading = false + ) + StatusRow( + label = stringResource(R.string.woopos_settings_local_catalog_last_full_update), + value = catalogStatus.lastFullUpdate, + isLoading = false + ) + } + is WooPosSettingsLocalCatalogState.CatalogStatus.LoadingStatus, + is WooPosSettingsLocalCatalogState.CatalogStatus.RefreshingCatalog -> { + StatusRow( + label = stringResource(R.string.woopos_settings_local_catalog_size), + value = null, + isLoading = true + ) + StatusRow( + label = stringResource(R.string.woopos_settings_local_catalog_last_update), + value = null, + isLoading = true + ) + StatusRow( + label = stringResource(R.string.woopos_settings_local_catalog_last_full_update), + value = null, + isLoading = true + ) + } } } } @@ -274,14 +276,44 @@ fun WooPosSettingsLocalCatalogScreenPreview() { WooPosTheme { WooPosSettingsLocalCatalogScreen( state = WooPosSettingsLocalCatalogState( - catalogStatus = CatalogStatus( + catalogStatus = WooPosSettingsLocalCatalogState.CatalogStatus.Available( catalogSize = "12.5 MB", lastUpdate = "2 hours ago", lastFullUpdate = "Yesterday at 3:45 PM" ), - allowCellularDataUpdate = true, - isLoading = false, - isRefreshing = false + allowCellularDataUpdate = true + ), + onToggleCellularData = {}, + onRefreshCatalog = {} + ) + } +} + + +@WooPosPreview +@Composable +fun WooPosSettingsLocalCatalogScreenLoadingPreview() { + WooPosTheme { + WooPosSettingsLocalCatalogScreen( + state = WooPosSettingsLocalCatalogState( + catalogStatus = WooPosSettingsLocalCatalogState.CatalogStatus.LoadingStatus, + allowCellularDataUpdate = true + ), + onToggleCellularData = {}, + onRefreshCatalog = {} + ) + } +} + + +@WooPosPreview +@Composable +fun WooPosSettingsLocalCatalogRefreshingPreview() { + WooPosTheme { + WooPosSettingsLocalCatalogScreen( + state = WooPosSettingsLocalCatalogState( + catalogStatus = WooPosSettingsLocalCatalogState.CatalogStatus.RefreshingCatalog, + allowCellularDataUpdate = true ), onToggleCellularData = {}, onRefreshCatalog = {} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogState.kt index 8872a3fc4ac4..b41e0c8595d8 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogState.kt @@ -1,14 +1,17 @@ package com.woocommerce.android.ui.woopos.settings.details.localcatalog data class WooPosSettingsLocalCatalogState( - val catalogStatus: CatalogStatus? = null, + val catalogStatus: CatalogStatus = CatalogStatus.LoadingStatus, val allowCellularDataUpdate: Boolean = false, - val isLoading: Boolean = false, - val isRefreshing: Boolean = false -) +) { + sealed class CatalogStatus { + data class Available( + val catalogSize: String, + val lastUpdate: String, + val lastFullUpdate: String + ) : CatalogStatus() -data class CatalogStatus( - val catalogSize: String, - val lastUpdate: String, - val lastFullUpdate: String -) + object LoadingStatus : CatalogStatus() + object RefreshingCatalog : CatalogStatus() + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt index 34ebd7ad5c28..0285dc3c7537 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt @@ -31,7 +31,7 @@ class WooPosSettingsLocalCatalogViewModel @Inject constructor( private fun loadCatalogStatus() { viewModelScope.launch { - _state.update { it.copy(isLoading = true) } + _state.update { it.copy(catalogStatus = WooPosSettingsLocalCatalogState.CatalogStatus.LoadingStatus) } val productsTimestamp = syncTimestampManager.getProductsLastSyncTimestamp() val variationsTimestamp = syncTimestampManager.getVariationsLastSyncTimestamp() @@ -41,17 +41,14 @@ class WooPosSettingsLocalCatalogViewModel @Inject constructor( variationsTimestamp ) - val catalogStatus = CatalogStatus( + val catalogStatus = WooPosSettingsLocalCatalogState.CatalogStatus.Available( catalogSize = "8.3 MB", // TBD local catalog: Replace with actual catalog size lastUpdate = formattedTimestamp, lastFullUpdate = formattedTimestamp // TBD local catalog: Replace with full sync timestamp ) _state.update { - it.copy( - catalogStatus = catalogStatus, - isLoading = false - ) + it.copy(catalogStatus = catalogStatus) } } } @@ -67,7 +64,10 @@ class WooPosSettingsLocalCatalogViewModel @Inject constructor( fun runFullCatalogSync() { viewModelScope.launch { - _state.update { it.copy(isRefreshing = true) } + val backupCatalogData = + (_state.value.catalogStatus as? WooPosSettingsLocalCatalogState.CatalogStatus.Available) + + _state.update { it.copy(catalogStatus = WooPosSettingsLocalCatalogState.CatalogStatus.RefreshingCatalog) } val result = localCatalogSyncRepository.syncLocalCatalogFull(selectedSite.get()) @@ -77,10 +77,10 @@ class WooPosSettingsLocalCatalogViewModel @Inject constructor( } is PosLocalCatalogSyncResult.Failure -> { // TBD local catalog: Handle errors + backupCatalogData?.let { _state.update { it.copy(catalogStatus = backupCatalogData) } } + } } - - _state.update { it.copy(isRefreshing = false) } } } } From be52b507814785b50f7cdbc2a688a5a19bc65f79 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Thu, 9 Oct 2025 07:32:20 +0200 Subject: [PATCH 15/16] Disable refresh catalog button when loading status --- .../localcatalog/WooPosSettingsLocalCatalogScreen.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt index fd731b067d92..ca071b9778be 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt @@ -81,7 +81,7 @@ private fun WooPosSettingsLocalCatalogScreen( RefreshSection( onRefreshCatalog = onRefreshCatalog, - isRefreshing = state.catalogStatus is WooPosSettingsLocalCatalogState.CatalogStatus.RefreshingCatalog + catalogStatus = state.catalogStatus ) } } @@ -195,7 +195,7 @@ private fun SettingsSection( @Composable private fun RefreshSection( onRefreshCatalog: () -> Unit, - isRefreshing: Boolean + catalogStatus: WooPosSettingsLocalCatalogState.CatalogStatus ) { Column( modifier = Modifier @@ -218,7 +218,11 @@ private fun RefreshSection( WooPosButton( modifier = Modifier.fillMaxWidth(), onClick = onRefreshCatalog, - state = if (isRefreshing) WooPosButtonState.LOADING else WooPosButtonState.ENABLED, + state = when (catalogStatus) { + is WooPosSettingsLocalCatalogState.CatalogStatus.Available -> WooPosButtonState.ENABLED + WooPosSettingsLocalCatalogState.CatalogStatus.LoadingStatus -> WooPosButtonState.DISABLED + WooPosSettingsLocalCatalogState.CatalogStatus.RefreshingCatalog -> WooPosButtonState.LOADING + }, text = stringResource(R.string.woopos_settings_local_catalog_refresh_button) ) } From 240cb05a4b562fdf6425e4279c610e8b7252e171 Mon Sep 17 00:00:00 2001 From: malinajirka Date: Thu, 9 Oct 2025 07:33:35 +0200 Subject: [PATCH 16/16] Fix detekt --- .../details/localcatalog/WooPosSettingsLocalCatalogScreen.kt | 2 -- .../details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt | 1 - 2 files changed, 3 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt index ca071b9778be..d85fc57531ff 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogScreen.kt @@ -293,7 +293,6 @@ fun WooPosSettingsLocalCatalogScreenPreview() { } } - @WooPosPreview @Composable fun WooPosSettingsLocalCatalogScreenLoadingPreview() { @@ -309,7 +308,6 @@ fun WooPosSettingsLocalCatalogScreenLoadingPreview() { } } - @WooPosPreview @Composable fun WooPosSettingsLocalCatalogRefreshingPreview() { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt index 0285dc3c7537..e9d59b82aacf 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/settings/details/localcatalog/WooPosSettingsLocalCatalogViewModel.kt @@ -78,7 +78,6 @@ class WooPosSettingsLocalCatalogViewModel @Inject constructor( is PosLocalCatalogSyncResult.Failure -> { // TBD local catalog: Handle errors backupCatalogData?.let { _state.update { it.copy(catalogStatus = backupCatalogData) } } - } } }