From cc0384db4f5c107a367df63d9009c707c794250b Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 13 Aug 2026 15:35:14 +0200 Subject: [PATCH 1/5] Add filter history analytics events Adds the 4 filter history Tracks events (button tapped, past filter applied/removed, cleared) with a source property, mirroring iOS. Part 2 of WOOMOB-3326 (WOOMOB-3825). --- .../com/woocommerce/android/analytics/AnalyticsEvent.kt | 6 ++++++ .../com/woocommerce/android/analytics/AnalyticsTracker.kt | 2 ++ 2 files changed, 8 insertions(+) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsEvent.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsEvent.kt index ab5cc293b04..bdc712d91dd 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsEvent.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsEvent.kt @@ -201,6 +201,12 @@ enum class AnalyticsEvent(override val siteless: Boolean = false) : IAnalyticsEv FILTER_ORDERS_BY_STATUS_DIALOG_OPTION_SELECTED, ORDER_FILTER_LIST_CLEAR_MENU_BUTTON_TAPPED, + // -- Filter History (shared by order and product lists, property: source = orders/products) + FILTER_HISTORY_BUTTON_TAPPED, + FILTER_HISTORY_PAST_FILTER_APPLIED, + FILTER_HISTORY_PAST_FILTER_REMOVED, + FILTER_HISTORY_CLEARED, + ORDERS_LIST_BULK_UPDATE_SELECTION_ENABLED, ORDERS_LIST_BULK_UPDATE_REQUESTED, ORDERS_LIST_BULK_UPDATE_CONFIRMED, diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsTracker.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsTracker.kt index b1ef436ade5..18daf3507ce 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsTracker.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/analytics/AnalyticsTracker.kt @@ -236,6 +236,8 @@ class AnalyticsTracker private constructor( const val KEY_DATE = "date" const val KEY_GRANULARITY = "granularity" const val KEY_SOURCE = "source" + const val VALUE_FILTER_HISTORY_SOURCE_ORDERS = "orders" + const val VALUE_FILTER_HISTORY_SOURCE_PRODUCTS = "products" const val KEY_WAITING_TIME = "waiting_time" const val KEY_IS_NON_ATOMIC = "is_non_atomic" const val KEY_CAUSE = "cause" From cf9574f3e09c9a4f281a47b714092c3949826c9e Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Thu, 13 Aug 2026 15:35:20 +0200 Subject: [PATCH 2/5] Add shared filter history screen and ViewModel Adds FilterHistoryFragment, Compose screen and ViewModel (single-select, swipe-to-delete, clear-all), hosted in both filter nav graphs. Part 2 of WOOMOB-3326 (WOOMOB-3825). --- .../ui/filters/FilterHistoryFragment.kt | 53 +++ .../ui/filters/FilterHistoryRepository.kt | 5 +- .../android/ui/filters/FilterHistoryScreen.kt | 312 ++++++++++++++++++ .../ui/filters/FilterHistoryViewModel.kt | 114 +++++++ .../src/main/res/drawable/ic_history_24dp.xml | 10 + .../navigation/nav_graph_order_filters.xml | 15 + .../navigation/nav_graph_product_filters.xml | 11 + WooCommerce/src/main/res/values/strings.xml | 10 + .../ui/filters/FilterHistoryViewModelTest.kt | 180 ++++++++++ 9 files changed, 709 insertions(+), 1 deletion(-) create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryFragment.kt create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryScreen.kt create mode 100644 WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryViewModel.kt create mode 100644 WooCommerce/src/main/res/drawable/ic_history_24dp.xml create mode 100644 WooCommerce/src/test/kotlin/com/woocommerce/android/ui/filters/FilterHistoryViewModelTest.kt diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryFragment.kt new file mode 100644 index 00000000000..48c81ae31d6 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryFragment.kt @@ -0,0 +1,53 @@ +package com.woocommerce.android.ui.filters + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.woocommerce.android.extensions.navigateBackWithResult +import com.woocommerce.android.ui.base.BaseFragment +import com.woocommerce.android.ui.base.UIMessageResolver +import com.woocommerce.android.ui.compose.composeView +import com.woocommerce.android.ui.main.AppBarStatus +import com.woocommerce.android.viewmodel.MultiLiveEvent +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class FilterHistoryFragment : BaseFragment() { + private val viewModel: FilterHistoryViewModel by viewModels() + + @Inject lateinit var uiMessageResolver: UIMessageResolver + + override val activityAppBarStatus: AppBarStatus + get() = AppBarStatus.Hidden + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return composeView { + FilterHistoryScreen(viewModel) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.event.observe(viewLifecycleOwner) { event -> + when (event) { + is MultiLiveEvent.Event.Exit -> findNavController().navigateUp() + is MultiLiveEvent.Event.ExitWithResult<*> -> { + navigateBackWithResult( + FilterHistoryViewModel.FILTER_HISTORY_RESULT_KEY, + event.data + ) + } + is MultiLiveEvent.Event.ShowSnackbar -> uiMessageResolver.showSnack(event.message) + else -> event.isHandled = false + } + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryRepository.kt index 0fb0158eaef..c766b409053 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryRepository.kt @@ -1,9 +1,11 @@ package com.woocommerce.android.ui.filters +import android.os.Parcelable import com.woocommerce.android.tools.SelectedSite import dagger.Reusable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.parcelize.Parcelize import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId import org.wordpress.android.fluxc.persistence.dao.FilterHistoryDao import org.wordpress.android.fluxc.persistence.entity.FilterHistoryEntity @@ -79,8 +81,9 @@ enum class FilterHistoryType { * @param readableString human-readable summary of the filter, shown in the history list. * @param payload canonical serialization of the selection, decoded by the per-surface consumer. */ +@Parcelize data class SavedFilter( val id: Long, val readableString: String, val payload: String -) +) : Parcelable diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryScreen.kt new file mode 100644 index 00000000000..8c7436e30e9 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryScreen.kt @@ -0,0 +1,312 @@ +package com.woocommerce.android.ui.filters + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.rememberSwipeToDismissBoxState +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.graphics.vector.ImageVector +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import com.woocommerce.android.R +import com.woocommerce.android.ui.compose.component.AlertDialog +import com.woocommerce.android.ui.compose.component.SelectionCheck +import com.woocommerce.android.ui.compose.component.Toolbar +import com.woocommerce.android.ui.compose.component.WCTextButton +import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews +import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground +import com.woocommerce.android.ui.filters.FilterHistoryViewModel.ViewState + +@Composable +fun FilterHistoryScreen(viewModel: FilterHistoryViewModel) { + val viewState by viewModel.viewState.collectAsState() + FilterHistoryScreen( + viewState = viewState, + onFilterClick = viewModel::onFilterSelected, + onApplyClick = viewModel::onApplyClicked, + onCancelClick = viewModel::onCancelClicked, + onDeleteFilter = viewModel::onDeleteFilter, + onClearHistoryClick = viewModel::onClearHistoryClicked, + onClearHistoryConfirm = viewModel::onClearHistoryConfirmed, + onClearHistoryDismiss = viewModel::onClearHistoryDismissed + ) +} + +@Composable +fun FilterHistoryScreen( + viewState: ViewState, + onFilterClick: (SavedFilter) -> Unit, + onApplyClick: () -> Unit, + onCancelClick: () -> Unit, + onDeleteFilter: (SavedFilter) -> Unit, + onClearHistoryClick: () -> Unit, + onClearHistoryConfirm: () -> Unit, + onClearHistoryDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + modifier = modifier, + topBar = { + Toolbar( + title = stringResource(id = R.string.filter_history_title), + navigationIcon = ImageVector.vectorResource(id = R.drawable.ic_gridicons_cross_24dp), + navigationIconContentDescription = stringResource(id = R.string.filter_history_cancel), + onNavigationButtonClick = onCancelClick, + actions = { + WCTextButton( + onClick = onApplyClick, + enabled = viewState.isApplyEnabled, + text = stringResource(id = R.string.filter_history_apply) + ) + } + ) + } + ) { paddingValues -> + if (viewState.isEmpty) { + FilterHistoryEmptyState( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) + } else { + FilterHistoryList( + viewState = viewState, + onFilterClick = onFilterClick, + onDeleteFilter = onDeleteFilter, + onClearHistoryClick = onClearHistoryClick, + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) + } + } + + if (viewState.showClearHistoryConfirmation) { + ClearHistoryConfirmationDialog( + onConfirm = onClearHistoryConfirm, + onDismiss = onClearHistoryDismiss + ) + } +} + +@Composable +private fun FilterHistoryList( + viewState: ViewState, + onFilterClick: (SavedFilter) -> Unit, + onDeleteFilter: (SavedFilter) -> Unit, + onClearHistoryClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + LazyColumn(modifier = Modifier.weight(1f)) { + item { + Text( + text = stringResource(id = R.string.filter_history_recent_header).uppercase(), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding( + horizontal = dimensionResource(id = R.dimen.major_100), + vertical = dimensionResource(id = R.dimen.minor_100) + ) + ) + } + items(items = viewState.filters, key = { it.id }) { filter -> + SwipeableFilterHistoryRow( + filter = filter, + isSelected = filter.id == viewState.selectedFilter?.id, + onClick = { onFilterClick(filter) }, + onDelete = { onDeleteFilter(filter) } + ) + Divider(modifier = Modifier.padding(start = dimensionResource(id = R.dimen.major_100))) + } + } + Divider() + WCTextButton( + onClick = onClearHistoryClick, + text = stringResource(id = R.string.filter_history_clear), + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(id = R.dimen.minor_100)) + ) + } +} + +@Composable +private fun SwipeableFilterHistoryRow( + filter: SavedFilter, + isSelected: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + val swipeToDismissBoxState = rememberSwipeToDismissBoxState() + SwipeToDismissBox( + state = swipeToDismissBoxState, + modifier = modifier.fillMaxWidth(), + enableDismissFromStartToEnd = false, + backgroundContent = { + if (swipeToDismissBoxState.dismissDirection == SwipeToDismissBoxValue.EndToStart) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete_filled_24dp), + contentDescription = stringResource(id = R.string.filter_history_delete), + modifier = Modifier + .fillMaxSize() + .background(colorResource(id = R.color.woo_red_50)) + .wrapContentSize(Alignment.CenterEnd) + .padding(end = dimensionResource(id = R.dimen.major_100)), + tint = colorResource(id = R.color.woo_white) + ) + } + }, + onDismiss = { + if (it == SwipeToDismissBoxValue.EndToStart) onDelete() + it != SwipeToDismissBoxValue.EndToStart + } + ) { + FilterHistoryRow( + filter = filter, + isSelected = isSelected, + onClick = onClick + ) + } +} + +@Composable +private fun FilterHistoryRow( + filter: SavedFilter, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colors.surface) + .clickable { onClick() } + .padding(dimensionResource(id = R.dimen.major_100)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.major_100)), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = filter.readableString, + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + modifier = Modifier.weight(1f) + ) + SelectionCheck( + isSelected = isSelected, + onSelectionChange = { onClick() } + ) + } +} + +@Composable +private fun FilterHistoryEmptyState(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(dimensionResource(id = R.dimen.major_100)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_history_24dp), + contentDescription = null, + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + modifier = Modifier.size(dimensionResource(id = R.dimen.image_major_64)) + ) + Text( + text = stringResource(id = R.string.filter_history_empty), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = dimensionResource(id = R.dimen.major_100)) + ) + } +} + +@Composable +private fun ClearHistoryConfirmationDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + text = { Text(text = stringResource(id = R.string.filter_history_clear_confirmation)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = stringResource(id = R.string.filter_history_clear)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(id = R.string.filter_history_cancel)) + } + }, + neutralButton = {} + ) +} + +@LightDarkThemePreviews +@Composable +private fun FilterHistoryScreenPreview() { + WooThemeWithBackground { + FilterHistoryScreen( + viewState = ViewState( + filters = listOf( + SavedFilter(id = 1, readableString = "Processing, Last 30 days", payload = ""), + SavedFilter(id = 2, readableString = "Completed", payload = ""), + SavedFilter(id = 3, readableString = "Cancelled, John Doe", payload = "") + ), + selectedFilter = SavedFilter(id = 2, readableString = "Completed", payload = "") + ), + onFilterClick = {}, + onApplyClick = {}, + onCancelClick = {}, + onDeleteFilter = {}, + onClearHistoryClick = {}, + onClearHistoryConfirm = {}, + onClearHistoryDismiss = {} + ) + } +} + +@LightDarkThemePreviews +@Composable +private fun FilterHistoryEmptyPreview() { + WooThemeWithBackground { + FilterHistoryScreen( + viewState = ViewState(filters = emptyList()), + onFilterClick = {}, + onApplyClick = {}, + onCancelClick = {}, + onDeleteFilter = {}, + onClearHistoryClick = {}, + onClearHistoryConfirm = {}, + onClearHistoryDismiss = {} + ) + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryViewModel.kt new file mode 100644 index 00000000000..56470aeeba1 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryViewModel.kt @@ -0,0 +1,114 @@ +package com.woocommerce.android.ui.filters + +import androidx.lifecycle.SavedStateHandle +import com.woocommerce.android.analytics.AnalyticsEvent +import com.woocommerce.android.analytics.AnalyticsTracker +import com.woocommerce.android.analytics.AnalyticsTrackerWrapper +import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.Exit +import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ExitWithResult +import com.woocommerce.android.viewmodel.ScopedViewModel +import com.woocommerce.android.viewmodel.navArgs +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Shared ViewModel backing the "Filter History" screen for both the order and product lists. + * The concrete filter surface is passed as the [FilterHistoryFragmentArgs.filterType] navigation + * argument, which also drives the analytics `source` property. + * + * The screen deals only in [SavedFilter]s (id + readable label + opaque payload); decoding the + * payload back into a concrete filter selection is the caller's responsibility (see the order and + * product filter screens). Applying a filter returns the chosen [SavedFilter] as a nav result under + * [FILTER_HISTORY_RESULT_KEY]. + */ +@HiltViewModel +class FilterHistoryViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val repository: FilterHistoryRepository, + private val analyticsTrackerWrapper: AnalyticsTrackerWrapper +) : ScopedViewModel(savedStateHandle) { + private val navArgs by savedStateHandle.navArgs() + private val filterType: FilterHistoryType get() = navArgs.filterType + + private val selectedFilter = MutableStateFlow(null) + private val showClearHistoryConfirmation = MutableStateFlow(false) + + val viewState = combine( + repository.observeHistory(filterType), + selectedFilter, + showClearHistoryConfirmation + ) { filters, selected, showConfirmation -> + ViewState( + filters = filters, + selectedFilter = filters.firstOrNull { it.id == selected?.id }, + showClearHistoryConfirmation = showConfirmation + ) + }.toStateFlow(ViewState()) + + fun onFilterSelected(filter: SavedFilter) { + selectedFilter.value = filter + } + + fun onApplyClicked() { + val filter = selectedFilter.value ?: return + track(AnalyticsEvent.FILTER_HISTORY_PAST_FILTER_APPLIED) + triggerEvent(ExitWithResult(filter)) + } + + fun onCancelClicked() { + triggerEvent(Exit) + } + + fun onDeleteFilter(filter: SavedFilter) { + launch { + repository.remove(filter) + if (selectedFilter.value?.id == filter.id) { + selectedFilter.value = null + } + track(AnalyticsEvent.FILTER_HISTORY_PAST_FILTER_REMOVED) + } + } + + fun onClearHistoryClicked() { + showClearHistoryConfirmation.value = true + } + + fun onClearHistoryDismissed() { + showClearHistoryConfirmation.value = false + } + + fun onClearHistoryConfirmed() { + launch { + repository.clear(filterType) + selectedFilter.value = null + showClearHistoryConfirmation.value = false + track(AnalyticsEvent.FILTER_HISTORY_CLEARED) + } + } + + private fun track(event: AnalyticsEvent) { + analyticsTrackerWrapper.track(event, mapOf(AnalyticsTracker.KEY_SOURCE to analyticsSource)) + } + + private val analyticsSource: String + get() = when (filterType) { + FilterHistoryType.ORDERS -> AnalyticsTracker.VALUE_FILTER_HISTORY_SOURCE_ORDERS + FilterHistoryType.PRODUCTS -> AnalyticsTracker.VALUE_FILTER_HISTORY_SOURCE_PRODUCTS + } + + data class ViewState( + val filters: List = emptyList(), + val selectedFilter: SavedFilter? = null, + val showClearHistoryConfirmation: Boolean = false + ) { + val isEmpty: Boolean get() = filters.isEmpty() + val isApplyEnabled: Boolean get() = selectedFilter != null + } + + companion object { + const val FILTER_HISTORY_RESULT_KEY = "filter_history_result" + } +} diff --git a/WooCommerce/src/main/res/drawable/ic_history_24dp.xml b/WooCommerce/src/main/res/drawable/ic_history_24dp.xml new file mode 100644 index 00000000000..d4fa3ee6134 --- /dev/null +++ b/WooCommerce/src/main/res/drawable/ic_history_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/WooCommerce/src/main/res/navigation/nav_graph_order_filters.xml b/WooCommerce/src/main/res/navigation/nav_graph_order_filters.xml index 472393d805c..f4c66d7b235 100644 --- a/WooCommerce/src/main/res/navigation/nav_graph_order_filters.xml +++ b/WooCommerce/src/main/res/navigation/nav_graph_order_filters.xml @@ -23,6 +23,13 @@ app:exitAnim="@anim/default_exit_anim" app:popEnterAnim="@anim/default_pop_enter_anim" app:popExitAnim="@anim/default_pop_exit_anim" /> + + + + diff --git a/WooCommerce/src/main/res/navigation/nav_graph_product_filters.xml b/WooCommerce/src/main/res/navigation/nav_graph_product_filters.xml index 58e85e1f6eb..e54ca3ec057 100644 --- a/WooCommerce/src/main/res/navigation/nav_graph_product_filters.xml +++ b/WooCommerce/src/main/res/navigation/nav_graph_product_filters.xml @@ -37,6 +37,17 @@ app:nullable="false" app:argType="integer" /> + + + + Sales Channel Id: %d Selected filter option + + Filter History + Recent + Apply + Cancel + No past filters found + Clear History + Are you sure you want to clear all the filter history? + Delete + Filter history Order Status Date Range All orders diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/filters/FilterHistoryViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/filters/FilterHistoryViewModelTest.kt new file mode 100644 index 00000000000..c7bb04fd214 --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/filters/FilterHistoryViewModelTest.kt @@ -0,0 +1,180 @@ +package com.woocommerce.android.ui.filters + +import com.woocommerce.android.analytics.AnalyticsEvent +import com.woocommerce.android.analytics.AnalyticsTracker +import com.woocommerce.android.analytics.AnalyticsTrackerWrapper +import com.woocommerce.android.util.runAndCaptureValues +import com.woocommerce.android.viewmodel.BaseUnitTest +import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.Exit +import com.woocommerce.android.viewmodel.MultiLiveEvent.Event.ExitWithResult +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class FilterHistoryViewModelTest : BaseUnitTest() { + private val analyticsTrackerWrapper: AnalyticsTrackerWrapper = mock() + private val repository: FilterHistoryRepository = mock { + on { observeHistory(any()) } doReturn flowOf(FILTERS) + } + + private lateinit var viewModel: FilterHistoryViewModel + + private fun setup( + filterType: FilterHistoryType = FilterHistoryType.ORDERS, + prepareMocks: () -> Unit = {} + ) { + prepareMocks() + viewModel = FilterHistoryViewModel( + savedStateHandle = FilterHistoryFragmentArgs(filterType).toSavedStateHandle(), + repository = repository, + analyticsTrackerWrapper = analyticsTrackerWrapper + ) + } + + @Test + fun `given saved filters, when observed, then they are exposed newest-first`() = testBlocking { + setup() + + val state = viewModel.viewState.runAndCaptureValues { }.last() + + assertThat(state.filters).isEqualTo(FILTERS) + assertThat(state.isEmpty).isFalse() + } + + @Test + fun `given no saved filters, when observed, then state is empty`() = testBlocking { + setup { + whenever(repository.observeHistory(any())).thenReturn(flowOf(emptyList())) + } + + val state = viewModel.viewState.runAndCaptureValues { }.last() + + assertThat(state.isEmpty).isTrue() + assertThat(state.isApplyEnabled).isFalse() + } + + @Test + fun `when a filter is selected, then it becomes selected and apply is enabled`() = testBlocking { + setup() + + val state = viewModel.viewState.runAndCaptureValues { + viewModel.onFilterSelected(FILTER_2) + }.last() + + assertThat(state.selectedFilter).isEqualTo(FILTER_2) + assertThat(state.isApplyEnabled).isTrue() + } + + @Test + fun `given a selection, when apply is clicked, then applied is tracked and selection is returned`() = testBlocking { + setup() + viewModel.onFilterSelected(FILTER_2) + + val events = viewModel.event.runAndCaptureValues { + viewModel.onApplyClicked() + } + + assertThat(events.last()).isInstanceOf(ExitWithResult::class.java) + assertThat((events.last() as ExitWithResult<*>).data).isEqualTo(FILTER_2) + verify(analyticsTrackerWrapper).track( + AnalyticsEvent.FILTER_HISTORY_PAST_FILTER_APPLIED, + mapOf(AnalyticsTracker.KEY_SOURCE to AnalyticsTracker.VALUE_FILTER_HISTORY_SOURCE_ORDERS) + ) + } + + @Test + fun `given products source, when apply is clicked, then source is products`() = testBlocking { + setup(filterType = FilterHistoryType.PRODUCTS) + viewModel.onFilterSelected(FILTER_1) + + viewModel.onApplyClicked() + + verify(analyticsTrackerWrapper).track( + AnalyticsEvent.FILTER_HISTORY_PAST_FILTER_APPLIED, + mapOf(AnalyticsTracker.KEY_SOURCE to AnalyticsTracker.VALUE_FILTER_HISTORY_SOURCE_PRODUCTS) + ) + } + + @Test + fun `given no selection, when apply is clicked, then nothing is tracked`() = testBlocking { + setup() + + viewModel.onApplyClicked() + + verify(analyticsTrackerWrapper, never()).track(any(), any()) + } + + @Test + fun `when a filter is deleted, then it is removed and removed is tracked`() = testBlocking { + setup() + + viewModel.onDeleteFilter(FILTER_1) + + verify(repository).remove(FILTER_1) + verify(analyticsTrackerWrapper).track( + AnalyticsEvent.FILTER_HISTORY_PAST_FILTER_REMOVED, + mapOf(AnalyticsTracker.KEY_SOURCE to AnalyticsTracker.VALUE_FILTER_HISTORY_SOURCE_ORDERS) + ) + } + + @Test + fun `when clear history is clicked, then the confirmation is shown`() = testBlocking { + setup() + + val state = viewModel.viewState.runAndCaptureValues { + viewModel.onClearHistoryClicked() + }.last() + + assertThat(state.showClearHistoryConfirmation).isTrue() + } + + @Test + fun `when clear history is confirmed, then history is cleared and cleared is tracked`() = testBlocking { + setup() + + viewModel.onClearHistoryConfirmed() + + verify(repository).clear(FilterHistoryType.ORDERS) + verify(analyticsTrackerWrapper).track( + AnalyticsEvent.FILTER_HISTORY_CLEARED, + mapOf(AnalyticsTracker.KEY_SOURCE to AnalyticsTracker.VALUE_FILTER_HISTORY_SOURCE_ORDERS) + ) + } + + @Test + fun `when clear history is dismissed, then the confirmation is hidden`() = testBlocking { + setup() + viewModel.onClearHistoryClicked() + + val state = viewModel.viewState.runAndCaptureValues { + viewModel.onClearHistoryDismissed() + }.last() + + assertThat(state.showClearHistoryConfirmation).isFalse() + } + + @Test + fun `when cancel is clicked, then the screen exits`() = testBlocking { + setup() + + val events = viewModel.event.runAndCaptureValues { + viewModel.onCancelClicked() + } + + assertThat(events.last()).isEqualTo(Exit) + } + + private companion object { + val FILTER_1 = SavedFilter(id = 1, readableString = "Processing", payload = "status=processing") + val FILTER_2 = SavedFilter(id = 2, readableString = "Completed", payload = "status=completed") + val FILTERS = listOf(FILTER_1, FILTER_2) + } +} From 62a9ea94d53487a0f4c6008d1b954e8392773688 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Fri, 14 Aug 2026 17:07:22 +0200 Subject: [PATCH 3/5] Add filter history nav transition to the product filter action --- .../src/main/res/navigation/nav_graph_product_filters.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/WooCommerce/src/main/res/navigation/nav_graph_product_filters.xml b/WooCommerce/src/main/res/navigation/nav_graph_product_filters.xml index e54ca3ec057..18cf0fd56b1 100644 --- a/WooCommerce/src/main/res/navigation/nav_graph_product_filters.xml +++ b/WooCommerce/src/main/res/navigation/nav_graph_product_filters.xml @@ -39,7 +39,11 @@ + app:destination="@id/productFilterHistoryFragment" + app:enterAnim="@anim/default_enter_anim" + app:exitAnim="@anim/default_exit_anim" + app:popEnterAnim="@anim/default_pop_enter_anim" + app:popExitAnim="@anim/default_pop_exit_anim" /> Date: Fri, 14 Aug 2026 17:14:02 +0200 Subject: [PATCH 4/5] Use Material 3 in the filter history screen --- .../android/ui/filters/FilterHistoryScreen.kt | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryScreen.kt index 8c7436e30e9..f9f112952fe 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/filters/FilterHistoryScreen.kt @@ -12,14 +12,14 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TextButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -127,8 +127,8 @@ private fun FilterHistoryList( item { Text( text = stringResource(id = R.string.filter_history_recent_header).uppercase(), - style = MaterialTheme.typography.caption, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), modifier = Modifier.padding( horizontal = dimensionResource(id = R.dimen.major_100), vertical = dimensionResource(id = R.dimen.minor_100) @@ -142,10 +142,10 @@ private fun FilterHistoryList( onClick = { onFilterClick(filter) }, onDelete = { onDeleteFilter(filter) } ) - Divider(modifier = Modifier.padding(start = dimensionResource(id = R.dimen.major_100))) + HorizontalDivider(modifier = Modifier.padding(start = dimensionResource(id = R.dimen.major_100))) } } - Divider() + HorizontalDivider() WCTextButton( onClick = onClearHistoryClick, text = stringResource(id = R.string.filter_history_clear), @@ -206,7 +206,7 @@ private fun FilterHistoryRow( Row( modifier = modifier .fillMaxWidth() - .background(MaterialTheme.colors.surface) + .background(MaterialTheme.colorScheme.surface) .clickable { onClick() } .padding(dimensionResource(id = R.dimen.major_100)), horizontalArrangement = Arrangement.spacedBy(dimensionResource(id = R.dimen.major_100)), @@ -214,8 +214,8 @@ private fun FilterHistoryRow( ) { Text( text = filter.readableString, - style = MaterialTheme.typography.subtitle1, - color = MaterialTheme.colors.onSurface, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f) ) SelectionCheck( @@ -235,13 +235,13 @@ private fun FilterHistoryEmptyState(modifier: Modifier = Modifier) { Icon( painter = painterResource(id = R.drawable.ic_history_24dp), contentDescription = null, - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), modifier = Modifier.size(dimensionResource(id = R.dimen.image_major_64)) ) Text( text = stringResource(id = R.string.filter_history_empty), - style = MaterialTheme.typography.subtitle1, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), textAlign = TextAlign.Center, modifier = Modifier.padding(top = dimensionResource(id = R.dimen.major_100)) ) From 4eca5d49ab440e092ae4ea9b68e17c9da30fb898 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Fri, 14 Aug 2026 17:18:02 +0200 Subject: [PATCH 5/5] Drop unused filter history content description string --- WooCommerce/src/main/res/values/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 07f7eef3108..a38f1f3c297 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -867,7 +867,6 @@ Clear History Are you sure you want to clear all the filter history? Delete - Filter history Order Status Date Range All orders