Skip to content
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
24.4
-----

- [Internal] Add full-text search for POS product search with analytics tracking [https://github.com/woocommerce/woocommerce-android/pull/15484]

24.3
-----
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.woocommerce.android.ui.woopos.home.items

import com.woocommerce.android.R
import com.woocommerce.android.ui.woopos.common.composeui.component.WooPosSearchInputState
import com.woocommerce.android.ui.woopos.featureflags.IsPosProductsFtsEnabled
import com.woocommerce.android.ui.woopos.home.ChildToParentEvent
import com.woocommerce.android.ui.woopos.home.ParentToChildrenEvent
import com.woocommerce.android.ui.woopos.home.WooPosChildrenToParentEventSender
Expand All @@ -22,7 +21,6 @@ class WooPosItemsSearchHelper @Inject constructor(
private val childToParentEventSender: WooPosChildrenToParentEventSender,
private val parentToChildrenEventReceiver: WooPosParentToChildrenEventReceiver,
private val productsDataSource: WooPosProductsDataSource,
private val isFtsEnabled: IsPosProductsFtsEnabled,
) {
private lateinit var coroutineScope: CoroutineScope
private lateinit var viewStateFlow: MutableStateFlow<WooPosItemsToolbarViewState>
Expand Down Expand Up @@ -127,13 +125,7 @@ class WooPosItemsSearchHelper @Inject constructor(
wasLastStateClosed = false

val searchHintStringRes = when (viewStateFlow.value) {
is WooPosItemsToolbarViewState.ProductList -> {
if (isFtsEnabled()) {
R.string.woopos_search_products_and_variations
} else {
R.string.woopos_search_products
}
}
is WooPosItemsToolbarViewState.ProductList -> R.string.woopos_search_products_and_variations
is WooPosItemsToolbarViewState.CouponList -> R.string.woopos_search_coupons
is WooPosItemsToolbarViewState.VariationList -> error("Search is not applicable for variations list")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,14 @@ class WooPosProductsInDbDataSource @Inject constructor(
val result = localCatalogSearchDataSource.searchProducts(query)

result.fold(
onSuccess = { products ->
emit(SearchProductsResult.Local(products))
onSuccess = { dbSearchResult ->
emit(
SearchProductsResult.Local(
products = dbSearchResult.products,
searchTimeMillis = dbSearchResult.searchTimeMillis,
searchMethod = dbSearchResult.searchMethod,
)
)
},
onFailure = { error ->
emit(SearchProductsResult.Remote(Result.failure(error), 0L))
Expand Down Expand Up @@ -614,7 +620,13 @@ class WooPosProductsRemoteDataSource @Inject constructor(
override fun searchProducts(query: String): Flow<SearchProductsResult> = flow {
val localProducts = searchProductsDataSource.searchLocalProducts(query)
if (localProducts.isNotEmpty()) {
emit(SearchProductsResult.Local(localProducts))
emit(
SearchProductsResult.Local(
products = localProducts,
searchTimeMillis = 0L,
searchMethod = "in_memory_cache",
)
)
}

var remoteResult: Result<List<WooPosProductModel>>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import kotlinx.coroutines.flow.Flow
import org.wordpress.android.fluxc.model.LocalOrRemoteId

sealed class SearchProductsResult {
data class Local(val products: List<WooPosProductModel>) : SearchProductsResult()
data class Local(
val products: List<WooPosProductModel>,
val searchTimeMillis: Long,
val searchMethod: String,
) : SearchProductsResult()
data class Remote(
val productsResult: Result<List<WooPosProductModel>>,
val searchTimeMillis: Long = 0L
val searchTimeMillis: Long,
) : SearchProductsResult()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.woocommerce.android.ui.woopos.home.items.search
import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.ItemsNextPageLoaded
import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.PreSearchRecentTermTapped
import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.SearchRemoteResultsFetched
import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.SearchResultTapped
import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.SearchResultsFetched
import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEventConstant
import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker
import com.woocommerce.android.ui.woopos.util.analytics.WooPosGetTotalProductCount
Expand Down Expand Up @@ -39,6 +41,28 @@ class WooPosItemsSearchAnalyticsTracker @Inject constructor(
analyticsTracker.track(event)
}

suspend fun trackLocalSearchResults(
resultsCount: Int,
searchTimeMillis: Long,
searchMethod: String
) {
val event = SearchResultsFetched(
millisecondsSinceRequestSent = searchTimeMillis,
resultsCount = resultsCount,
source = "purchasable_items",
searchMethod = searchMethod,
)
analyticsTracker.track(event)
}

suspend fun trackSearchResultTapped(resultPosition: Int, resultType: String) {
val event = SearchResultTapped(
resultPosition = resultPosition,
resultType = resultType,
)
analyticsTracker.track(event)
}

fun isProductInTheLocalSearchResult(productId: Long): Boolean = localSearchProductIds.get().contains(productId)

fun storedLocalSearchResultIds(ids: List<Long>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ class WooPosItemsSearchViewModel @Inject constructor(
showLoadingState: Boolean
) {
analyticsTracker.storedLocalSearchResultIds(searchResult.products.map { it.remoteId })
analyticsTracker.trackLocalSearchResults(
resultsCount = searchResult.products.size,
searchTimeMillis = searchResult.searchTimeMillis,
searchMethod = searchResult.searchMethod,
)

if (searchResult.products.isEmpty()) {
_viewState.value = if (showLoadingState) {
Expand Down Expand Up @@ -274,6 +279,8 @@ class WooPosItemsSearchViewModel @Inject constructor(
item: WooPosItemSelectionViewState,
sourceType: WooPosAnalyticsEventConstant.ItemsListSourceType
) {
trackSearchResultTapped(item)

when (item) {
is WooPosItemSelectionViewState.Product.Simple -> {
viewModelScope.launch {
Expand Down Expand Up @@ -343,6 +350,24 @@ class WooPosItemsSearchViewModel @Inject constructor(
storeRecentSearch()
}

private fun trackSearchResultTapped(item: WooPosItemSelectionViewState) {
val contentState = _viewState.value as? WooPosItemsSearchViewState.Content ?: return
val position = contentState.items.indexOfFirst { it.id == item.id }
if (position < 0) return

val resultType = when (item) {
is WooPosItemSelectionViewState.Product.VariationSearchResult -> "variation"
else -> "product"
}

viewModelScope.launch {
analyticsTracker.trackSearchResultTapped(
resultPosition = position,
resultType = resultType,
)
}
}

private fun storeRecentSearch() {
(_viewState.value as? WooPosItemsSearchViewState.Content)?.let {
viewModelScope.launch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import com.woocommerce.android.tools.SelectedSite
import com.woocommerce.android.ui.woopos.common.data.models.WooPosProductModel
import com.woocommerce.android.ui.woopos.common.data.models.WooPosProductModelMapper
import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper
import com.woocommerce.android.ui.woopos.featureflags.IsPosProductsFtsEnabled
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.wordpress.android.fluxc.model.LocalOrRemoteId
Expand All @@ -20,13 +19,18 @@ class WooPosProductsSearchInDbDataSource @Inject constructor(
private val posLocalCatalogStore: WooPosLocalCatalogStore,
private val selectedSite: SelectedSite,
private val productMapper: WooPosProductModelMapper,
private val isFtsEnabled: IsPosProductsFtsEnabled,
private val logger: WooPosLogWrapper,
) {
companion object {
private const val PAGE_SIZE = 15
}

data class DbSearchResult(
val products: List<WooPosProductModel>,
val searchTimeMillis: Long,
val searchMethod: String,
)

private val searchOffset = AtomicInteger(0)
private val canLoadMoreResults = AtomicBoolean(false)
private var currentQuery: String = ""
Expand All @@ -35,7 +39,7 @@ class WooPosProductsSearchInDbDataSource @Inject constructor(
val hasMorePages: Boolean
get() = canLoadMoreResults.get()

suspend fun searchProducts(query: String): Result<List<WooPosProductModel>> = withContext(Dispatchers.IO) {
suspend fun searchProducts(query: String): Result<DbSearchResult> = withContext(Dispatchers.IO) {
searchOffset.set(0)
currentQuery = query
accumulatedResults.clear()
Expand All @@ -47,26 +51,15 @@ class WooPosProductsSearchInDbDataSource @Inject constructor(
return@withContext Result.success(accumulatedResults.toList())
}

performSearch(query)
performSearch(query).map { it.products }
}

private suspend fun performSearch(query: String): Result<List<WooPosProductModel>> {
private suspend fun performSearch(query: String): Result<DbSearchResult> {
val siteModel = selectedSite.getOrNull() ?: return Result.failure(
IllegalStateException("No site selected")
)
val siteId = LocalOrRemoteId.LocalId(siteModel.id)

return if (isFtsEnabled()) {
performFtsSearch(siteId, query)
} else {
performLikeSearch(siteId, query)
}
}

private suspend fun performFtsSearch(
siteId: LocalOrRemoteId.LocalId,
query: String
): Result<List<WooPosProductModel>> {
val startTime = System.currentTimeMillis()
val offset = searchOffset.get()
logger.d("performFtsSearch: query=\"$query\", offset=$offset, pageSize=$PAGE_SIZE")
Expand Down Expand Up @@ -100,7 +93,13 @@ class WooPosProductsSearchInDbDataSource @Inject constructor(
"(${accumulatedResults.size} total). Duration: ${duration}ms"
)

Result.success(accumulatedResults.toList())
Result.success(
DbSearchResult(
products = accumulatedResults.toList(),
searchTimeMillis = duration,
searchMethod = "fts",
)
)
},
onFailure = { error ->
val duration = System.currentTimeMillis() - startTime
Expand All @@ -109,33 +108,4 @@ class WooPosProductsSearchInDbDataSource @Inject constructor(
}
)
}

private suspend fun performLikeSearch(
siteId: LocalOrRemoteId.LocalId,
query: String
): Result<List<WooPosProductModel>> {
val result = posLocalCatalogStore.searchProducts(
siteId = siteId,
searchQuery = query,
pageSize = PAGE_SIZE,
offset = searchOffset.get()
)

return result.fold(
onSuccess = { products ->
val mappedProducts = products.map { entity ->
productMapper.fromEntity(entity)
}

accumulatedResults.addAll(mappedProducts)
canLoadMoreResults.set(products.size == PAGE_SIZE)
searchOffset.addAndGet(PAGE_SIZE)

Result.success(accumulatedResults.toList())
},
onFailure = { error ->
Result.failure(error)
}
)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.woocommerce.android.ui.woopos.localcatalog

import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper
import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent
import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker
import com.woocommerce.android.ui.woopos.util.datastore.WooPosPreferencesRepository
import com.woocommerce.android.ui.woopos.util.datastore.WooPosSyncTimestampManager
import kotlinx.coroutines.delay
Expand All @@ -20,6 +22,7 @@ class WooPosFileBasedSyncAction @Inject constructor(
private val preferencesRepository: WooPosPreferencesRepository,
private val syncTimestampManager: WooPosSyncTimestampManager,
private val logger: WooPosLogWrapper,
private val analyticsTracker: WooPosAnalyticsTracker,
) {
companion object {
private const val INITIAL_POLL_INTERVAL_MS = 3000L
Expand Down Expand Up @@ -212,11 +215,7 @@ class WooPosFileBasedSyncAction @Inject constructor(
)
}

syncWithFts.syncFtsForFullSync(
siteIdString = site.localId().value.toString(),
products = parsedData.products,
variations = parsedData.variations
)
syncFtsAndTrack(site, parsedData)

catalogFileDownloader.cleanupOldCatalogFiles(keepLatest = downloadedFile)

Expand Down Expand Up @@ -253,6 +252,23 @@ class WooPosFileBasedSyncAction @Inject constructor(
)
}

private suspend fun syncFtsAndTrack(site: SiteModel, parsedData: WooPosCatalogFileParser.ParsedCatalogData) {
val ftsSyncResult = syncWithFts.syncFtsForFullSync(
siteIdString = site.localId().value.toString(),
products = parsedData.products,
variations = parsedData.variations
)
ftsSyncResult?.let {
analyticsTracker.track(
WooPosAnalyticsEvent.Event.FtsIndexBuilt(
syncType = "full",
indexDurationMs = it.durationMs,
productsIndexed = it.productsIndexed,
)
)
}
}

private fun <T> Result<T>.onFailureLog(context: String): Result<T> {
onFailure { logger.e("WooPosFileBasedSyncAction: $context: ${it.message}") }
return this
Expand Down
Loading