diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/toolbar/WooPosHomeFloatingToolbarViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/toolbar/WooPosHomeFloatingToolbarViewModel.kt index 4e9133ce6841..50613882c0d0 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/toolbar/WooPosHomeFloatingToolbarViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/home/toolbar/WooPosHomeFloatingToolbarViewModel.kt @@ -21,6 +21,7 @@ import com.woocommerce.android.ui.woopos.home.toolbar.WooPosHomeFloatingToolbarU import com.woocommerce.android.ui.woopos.home.toolbar.WooPosHomeFloatingToolbarUIEvent.OnToolbarMenuClicked import com.woocommerce.android.ui.woopos.util.WooPosNetworkStatus import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.ExitTapped +import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.GoToOrdersTapped import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker import com.woocommerce.android.viewmodel.ResourceProvider import dagger.hilt.android.lifecycle.HiltViewModel @@ -87,6 +88,7 @@ class WooPosHomeFloatingToolbarViewModel @Inject constructor( R.string.woopos_orders_title -> { viewModelScope.launch { childrenToParentEventSender.sendToParent(ChildToParentEvent.NavigationEvent.ToOrders) + analyticsTracker.track(GoToOrdersTapped) } } R.string.woopos_settings_title -> { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersAnalyticsTracker.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersAnalyticsTracker.kt new file mode 100644 index 000000000000..6baf0c60260c --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersAnalyticsTracker.kt @@ -0,0 +1,79 @@ +package com.woocommerce.android.ui.woopos.orders + +import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.OrderDetailsEmailReceiptTapped +import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.OrderDetailsLoaded +import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.OrdersListFetched +import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.OrdersListNextPageLoaded +import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.OrdersListPullToRefreshTriggered +import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.OrdersListRowTapped +import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.OrdersListSearchButtonTapped +import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsEvent.Event.OrdersListSearchResultsFetched +import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker +import org.apache.commons.lang3.time.DateUtils.MILLIS_PER_DAY +import javax.inject.Inject + +class WooPosOrdersAnalyticsTracker @Inject constructor( + private val analyticsTracker: WooPosAnalyticsTracker +) { + suspend fun trackOrdersListFetched(elapsedMs: Long) { + analyticsTracker.track(OrdersListFetched(elapsedMs)) + } + + suspend fun trackOrdersListRowTapped( + orderId: Long, + orderStatus: String, + listPosition: Int, + createdAtMillis: Long + ) { + val daysSinceCreated = calculateDaysSinceCreated(createdAtMillis) + analyticsTracker.track( + OrdersListRowTapped( + orderId = orderId, + orderStatus = orderStatus, + listPosition = listPosition, + daysSinceCreated = daysSinceCreated + ) + ) + } + + suspend fun trackOrderDetailsLoaded( + orderId: Long, + orderStatus: String, + createdAtMillis: Long + ) { + val daysSinceCreated = calculateDaysSinceCreated(createdAtMillis) + analyticsTracker.track( + OrderDetailsLoaded( + orderId = orderId, + orderStatus = orderStatus, + daysSinceCreated = daysSinceCreated + ) + ) + } + + suspend fun trackOrdersListPullToRefreshTriggered() { + analyticsTracker.track(OrdersListPullToRefreshTriggered) + } + + suspend fun trackOrderDetailsEmailReceiptTapped() { + analyticsTracker.track(OrderDetailsEmailReceiptTapped) + } + + suspend fun trackOrdersListNextPageLoaded() { + analyticsTracker.track(OrdersListNextPageLoaded) + } + + suspend fun trackOrdersListSearchButtonTapped() { + analyticsTracker.track(OrdersListSearchButtonTapped) + } + + suspend fun trackOrdersListSearchResultsFetched(elapsedMs: Long) { + analyticsTracker.track(OrdersListSearchResultsFetched(elapsedMs)) + } + + private fun calculateDaysSinceCreated(createdAtMillis: Long): Int { + return ((System.currentTimeMillis() - createdAtMillis) / MILLIS_PER_DAY) + .toInt() + .coerceAtLeast(0) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt index f4da233235df..693b6bd709c0 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersScreen.kt @@ -497,7 +497,9 @@ fun WooPosOrdersScreenPreview() { status = PosOrderStatus( text = "Completed", colorKey = OrderStatusColorKey.COMPLETED - ) + ), + statusSlug = "Completed", + createdAtMillis = 1 ) val item2 = OrderItemViewState( id = 2, @@ -509,7 +511,9 @@ fun WooPosOrdersScreenPreview() { status = PosOrderStatus( text = "Processing", colorKey = OrderStatusColorKey.PROCESSING - ) + ), + statusSlug = "Completed", + createdAtMillis = 1 ) val details1 = sampleOrderDetails(id = 1L, number = "#014") diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt index a1d29237154f..c28acdc9ba57 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersState.kt @@ -49,7 +49,9 @@ data class OrderItemViewState( val total: String, val customerEmail: String?, val isSelected: Boolean, - val status: PosOrderStatus + val status: PosOrderStatus, + val statusSlug: String, + val createdAtMillis: Long ) @Immutable diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt index b91a8601d113..0499173d0878 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.launch import java.math.BigDecimal import java.util.Locale import javax.inject.Inject +import kotlin.time.TimeSource.Monotonic @HiltViewModel class WooPosOrdersViewModel @Inject constructor( @@ -39,6 +40,7 @@ class WooPosOrdersViewModel @Inject constructor( private val childrenToParentEventSender: WooPosChildrenToParentEventSender, private val formatPrice: WooPosFormatPrice, private val getOrderRefunds: WooPosGetOrderRefundsByOrderId, + private val ordersAnalyticsTracker: WooPosOrdersAnalyticsTracker ) : ViewModel() { private val _state = MutableStateFlow( @@ -72,6 +74,26 @@ class WooPosOrdersViewModel @Inject constructor( val current = _state.value as? WooPosOrdersState.Content ?: return val loadedItems = current.items as? WooPosOrdersState.Content.Items.Loaded ?: return + val keys = loadedItems.items.keys.toList() + val position = keys.indexOfFirst { it.id == orderId }.coerceAtLeast(0) + val selectedItem = keys.firstOrNull { it.id == orderId } + + selectedItem?.let { + viewModelScope.launch { + ordersAnalyticsTracker.trackOrdersListRowTapped( + orderId = it.id, + orderStatus = it.statusSlug, + listPosition = position, + createdAtMillis = it.createdAtMillis + ) + ordersAnalyticsTracker.trackOrderDetailsLoaded( + orderId = it.id, + orderStatus = it.statusSlug, + createdAtMillis = it.createdAtMillis + ) + } + } + val updatedItems = loadedItems.items.mapKeys { (item, _) -> item.copy(isSelected = item.id == orderId) } @@ -87,6 +109,10 @@ class WooPosOrdersViewModel @Inject constructor( } fun onRefresh() { + viewModelScope.launch { + ordersAnalyticsTracker.trackOrdersListPullToRefreshTriggered() + } + val currentState = _state.value _state.value = when (currentState) { is WooPosOrdersState.Content -> currentState.copy( @@ -129,6 +155,7 @@ class WooPosOrdersViewModel @Inject constructor( fun onEmailReceiptButtonClicked(orderId: Long) { viewModelScope.launch { + ordersAnalyticsTracker.trackOrderDetailsEmailReceiptTapped() childrenToParentEventSender.sendToParent( ToEmailReceipt(orderId) ) @@ -171,6 +198,7 @@ class WooPosOrdersViewModel @Inject constructor( val result = ordersDataSource.loadMore(normalizedQuery) if (result.isSuccess) { + ordersAnalyticsTracker.trackOrdersListNextPageLoaded() appendOrders(result.getOrThrow()) } else { _state.value = newState.copy(paginationState = WooPosPaginationState.Error) @@ -181,6 +209,10 @@ class WooPosOrdersViewModel @Inject constructor( fun onSearchEvent(event: WooPosSearchUIEvent) { when (event) { is WooPosSearchUIEvent.SearchIconClicked -> { + viewModelScope.launch { + ordersAnalyticsTracker.trackOrdersListSearchButtonTapped() + } + updateSearchState( WooPosSearchInputState.Open( input = WooPosSearchInputState.Open.Input.Hint( @@ -286,6 +318,8 @@ class WooPosOrdersViewModel @Inject constructor( paginationState = WooPosPaginationState.None ) } + + val mark = Monotonic.markNow() val result = ordersDataSource.searchOrders(query) when (result) { is SearchOrdersResult.Error -> { @@ -302,6 +336,9 @@ class WooPosOrdersViewModel @Inject constructor( } is SearchOrdersResult.Success -> { + val elapsedMs = mark.elapsedNow().inWholeMilliseconds + ordersAnalyticsTracker.trackOrdersListSearchResultsFetched(elapsedMs) + if (result.orders.isEmpty()) { _state.value = WooPosOrdersState.Content( items = WooPosOrdersState.Content.Items.NothingFound( @@ -323,6 +360,7 @@ class WooPosOrdersViewModel @Inject constructor( private fun loadOrders() { cancelJobs() + val mark = Monotonic.markNow() loadingJob = viewModelScope.launch { ordersDataSource.loadOrders().collect { result -> when (result) { @@ -344,6 +382,9 @@ class WooPosOrdersViewModel @Inject constructor( } is LoadOrdersResult.SuccessRemote -> { + val elapsedMs = mark.elapsedNow().inWholeMilliseconds + ordersAnalyticsTracker.trackOrdersListFetched(elapsedMs) + if (result.orders.isEmpty()) { _state.value = WooPosOrdersState.Empty( searchInputState = WooPosSearchInputState.Closed @@ -429,7 +470,9 @@ class WooPosOrdersViewModel @Inject constructor( status = PosOrderStatus( text = statusText, colorKey = OrderStatusColorKey.fromStatus(order.status) - ) + ), + statusSlug = order.status.toString(), + createdAtMillis = order.dateCreated.time ) } 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 83534602b730..de069a8567fd 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 @@ -126,6 +126,87 @@ sealed class WooPosAnalyticsEvent : IAnalyticsEvent { data object InteractionWithCustomerStarted : Event() { override val name: String = "interaction_with_customer_started" } + data object GoToOrdersTapped : Event() { + override val name: String = "orders_menu_item_tapped" + } + + data object OrdersListPullToRefreshTriggered : Event() { + override val name: String = "orders_list_pull_to_refresh" + } + + data object OrdersListNextPageLoaded : Event() { + override val name: String = "orders_list_next_page_loaded" + } + + data object OrderDetailsEmailReceiptTapped : Event() { + override val name: String = "order_details_email_receipt_tapped" + } + + data class OrdersListRowTapped( + val orderId: Long, + val orderStatus: String, + val listPosition: Int, + val daysSinceCreated: Int + ) : Event() { + override val name: String = "orders_list_row_tapped" + + init { + addProperties( + mapOf( + "order_id" to orderId.toString(), + "order_status" to orderStatus, + "list_position" to listPosition.toString(), + "days_since_created" to daysSinceCreated.toString() + ) + ) + } + } + + data class OrderDetailsLoaded( + val orderId: Long, + val orderStatus: String, + val daysSinceCreated: Int + ) : Event() { + override val name: String = "pos_order_details_loaded" + + init { + addProperties( + mapOf( + "order_id" to orderId.toString(), + "order_status" to orderStatus, + "days_since_created" to daysSinceCreated.toString() + ) + ) + } + } + + data class OrdersListFetched(val milimetersSinceRequestSent: Long) : Event() { + override val name: String = "orders_list_fetched" + + init { + addProperties( + mapOf( + "milliseconds_since_request_sent" to milimetersSinceRequestSent.toString() + ) + ) + } + } + + data class OrdersListSearchResultsFetched(val milimetersSinceRequestSent: Long) : Event() { + override val name: String = "pos_orders_list_search_results_fetched" + + init { + addProperties( + mapOf( + "milliseconds_since_request_sent" to milimetersSinceRequestSent.toString() + ) + ) + } + } + + data object OrdersListSearchButtonTapped : Event() { + override val name: String = "pos_orders_list_search_button_tapped" + } data class BarcodeScanned( val scanDurationMs: Long, diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt index 0c3f9820f0fe..f75171de2def 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModelTest.kt @@ -26,6 +26,7 @@ import org.junit.Rule import org.junit.Test import org.mockito.Mockito.mock import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -50,6 +51,7 @@ class WooPosOrdersViewModelTest { private val getOrderRefunds: WooPosGetOrderRefundsByOrderId = mock() private val providedLocale: Locale = Locale.US private val childrenToParentEventSender: WooPosChildrenToParentEventSender = mock() + private val ordersAnalyticsTracker: WooPosOrdersAnalyticsTracker = mock() private fun createViewModel(): WooPosOrdersViewModel { return WooPosOrdersViewModel( @@ -59,7 +61,8 @@ class WooPosOrdersViewModelTest { getProductById = getProductById, childrenToParentEventSender = childrenToParentEventSender, formatPrice = formatPrice, - getOrderRefunds = getOrderRefunds + getOrderRefunds = getOrderRefunds, + ordersAnalyticsTracker = ordersAnalyticsTracker ) } @@ -284,6 +287,7 @@ class WooPosOrdersViewModelTest { val hint = openState.input as WooPosSearchInputState.Open.Input.Hint assertThat(hint.hint).isEqualTo("Search orders") assertThat(openState.requestFocus).isTrue() + verify(ordersAnalyticsTracker).trackOrdersListSearchButtonTapped() } @Test @@ -375,6 +379,7 @@ class WooPosOrdersViewModelTest { val loadedItems = content.items as WooPosOrdersState.Content.Items.Loaded assertThat(loadedItems.items.keys.map { it.id }).containsExactly(1L, 2L, 3L, 4L) assertThat(content.paginationState).isEqualTo(WooPosPaginationState.None) + verify(ordersAnalyticsTracker).trackOrdersListNextPageLoaded() } @Test @@ -615,6 +620,7 @@ class WooPosOrdersViewModelTest { val loadedItems = content.items as WooPosOrdersState.Content.Items.Loaded assertThat(loadedItems.items.keys.map { it.id }).containsExactly(300L, 400L) assertThat(content.selectedDetails.id).isEqualTo(300L) + verify(ordersAnalyticsTracker).trackOrdersListPullToRefreshTriggered() } @Test @@ -632,6 +638,7 @@ class WooPosOrdersViewModelTest { // THEN verify(childrenToParentEventSender).sendToParent(ToEmailReceipt(123L)) + verify(ordersAnalyticsTracker).trackOrderDetailsEmailReceiptTapped() } @Test @@ -750,4 +757,64 @@ class WooPosOrdersViewModelTest { assertThat(after.selectedDetails).isEqualTo(beforeDetails) verify(dataSource).refreshOrderById(1L) } + + @Test + fun `given orders loaded, when selecting an order, then tracks OrdersListRowTapped and OrderDetailsLoaded`() = runTest { + whenever(dataSource.loadOrders()).thenReturn( + flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1), order(2), order(3)))) } + ) + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onOrderSelected(3L) + advanceUntilIdle() + + verify(ordersAnalyticsTracker).trackOrdersListRowTapped( + orderId = eq(3L), + orderStatus = any(), + listPosition = eq(2), + createdAtMillis = any() + ) + + verify(ordersAnalyticsTracker).trackOrderDetailsLoaded( + orderId = eq(3L), + orderStatus = any(), + createdAtMillis = any() + ) + } + + @Test + fun `when remote load succeeds, then tracks OrdersListFetched`() = runTest { + val cached = listOf(order(1)) + val remote = listOf(order(2), order(3)) + whenever(dataSource.loadOrders()).thenReturn( + flow { + emit(LoadOrdersResult.SuccessCache(cached)) + emit(LoadOrdersResult.SuccessRemote(remote)) + } + ) + + viewModel = createViewModel() + advanceUntilIdle() + + verify(ordersAnalyticsTracker).trackOrdersListFetched(any()) + } + + @Test + fun `when search succeeds, then tracks OrdersListSearchResultsFetched`() = runTest { + val query = "abc" + val searchResult = listOf(order(10), order(20)) + whenever(dataSource.loadOrders()).thenReturn( + flow { emit(LoadOrdersResult.SuccessRemote(listOf(order(1)))) } + ) + whenever(dataSource.searchOrders(query)).thenReturn(SearchOrdersResult.Success(searchResult)) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onSearchEvent(WooPosSearchUIEvent.Search(query, query.length)) + advanceUntilIdle() + + verify(ordersAnalyticsTracker).trackOrdersListSearchResultsFetched(any()) + } }