diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/DefaultOrderListItemLookup.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/DefaultOrderListItemLookup.kt new file mode 100644 index 00000000000..d1b04ccd209 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/DefaultOrderListItemLookup.kt @@ -0,0 +1,41 @@ +package com.woocommerce.android.ui.orders + +import android.view.MotionEvent +import androidx.recyclerview.selection.ItemDetailsLookup +import androidx.recyclerview.widget.RecyclerView +import com.woocommerce.android.ui.orders.list.OrderListAdapter +import com.woocommerce.android.ui.orders.list.OrderListItemUIType.OrderListItemUI + +class DefaultOrderListItemLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup() { + override fun getItemDetails(event: MotionEvent): ItemDetails? = + recyclerView + .findChildViewUnder(event.x, event.y) + ?.let { view -> + recyclerView.getChildViewHolder(view)?.let { viewHolder -> + val position = viewHolder.bindingAdapterPosition + val item = (recyclerView.adapter as? OrderListAdapter)?.currentList?.get(position) + if (item is OrderListItemUI) { + DefaultOrderItemDetailsLookup(position, item.orderId) + } else { + null + } + } + } +} + +class DefaultOrderItemDetailsLookup( + private val position: Int, + private val orderId: Long +) : ItemDetailsLookup.ItemDetails() { + override fun getPosition() = position + override fun getSelectionKey() = orderId +} + +class SelectableOrderItemDetailsLookup( + private val position: Int, + private val orderId: Long +) : ItemDetailsLookup.ItemDetails() { + override fun getPosition() = position + override fun getSelectionKey() = orderId + override fun inSelectionHotspot(e: MotionEvent) = true +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderSelectionItemKeyProvider.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderSelectionItemKeyProvider.kt new file mode 100644 index 00000000000..b776f59bedb --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/OrderSelectionItemKeyProvider.kt @@ -0,0 +1,26 @@ +package com.woocommerce.android.ui.orders + +import androidx.recyclerview.selection.ItemKeyProvider +import androidx.recyclerview.widget.RecyclerView +import com.woocommerce.android.ui.orders.list.OrderListAdapter +import com.woocommerce.android.ui.orders.list.OrderListItemUIType + +/** + * Class provides the selection library access to stable selection keys and identifying items + * presented by a [RecyclerView] instance. + */ +class OrderSelectionItemKeyProvider(private val recyclerView: RecyclerView) : + ItemKeyProvider(SCOPE_MAPPED) { + override fun getKey(position: Int): Long? { + return (recyclerView.adapter as? OrderListAdapter)?.currentList?.get(position)?.let { item -> + if (item is OrderListItemUIType.OrderListItemUI) item.orderId else null + } + } + + override fun getPosition(key: Long): Int { + return (recyclerView.adapter as? OrderListAdapter)?.currentList + ?.indexOfFirst { item -> + item is OrderListItemUIType.OrderListItemUI && item.orderId == key + } ?: RecyclerView.NO_POSITION + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListAdapter.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListAdapter.kt index 8208a26daa8..0fc82267a09 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListAdapter.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListAdapter.kt @@ -7,6 +7,7 @@ import android.view.ViewGroup import androidx.core.view.ViewCompat import androidx.paging.PagedList import androidx.paging.PagedListAdapter +import androidx.recyclerview.selection.SelectionTracker import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder @@ -36,6 +37,7 @@ class OrderListAdapter( var activeOrderStatusMap: Map = emptyMap() var allOrderIds: List = listOf() + var tracker: SelectionTracker? = null override fun getItemViewType(position: Int): Int { return when (getItem(position)) { @@ -90,7 +92,11 @@ class OrderListAdapter( "for position: $position" ) } - holder.onBind((item as OrderListItemUI), allOrderIds) + holder.onBind( + (item as OrderListItemUI), + allOrderIds, + isActivated = tracker?.isSelected(item.orderId) ?: false + ) } is SectionHeaderViewHolder -> { if (BuildConfig.DEBUG && item !is SectionHeader) { @@ -152,10 +158,17 @@ class OrderListAdapter( private var isNotCompleted = true private var orderId = SwipeToComplete.SwipeAbleViewHolder.EMPTY_SWIPED_ID private val extras = HashMap() - fun onBind(orderItemUI: OrderListItemUI, allOrderIds: List) { + + // Note that `isActivated` here is not the same as `isSelected`. + // - `isActivated` : Flag used when an item is long pressed to support multiple selection in bulk updating. + // - `isSelected` : Flag used for the tablet 2-panel mode to show the selected item in a different color. + fun onBind(orderItemUI: OrderListItemUI, allOrderIds: List, isActivated: Boolean = false) { // Grab the current context from the underlying view val ctx = this.itemView.context + // As suggested in https://developer.android.com/reference/androidx/recyclerview/selection/package-summary + viewBinding.root.isActivated = isActivated + viewBinding.orderDate.text = orderItemUI.dateCreated viewBinding.orderNum.text = "#${orderItemUI.orderNumber}" viewBinding.orderName.text = orderItemUI.orderName @@ -166,7 +179,7 @@ class OrderListAdapter( viewBinding.divider.visibility = if (orderItemUI.isLastItemInSection) View.GONE else View.VISIBLE when { - orderItemUI.isSelected -> { + orderItemUI.isSelected || isActivated -> { viewBinding.orderItemLayout.setBackgroundColor( viewBinding.root.context.getColor(R.color.color_item_selected) ) @@ -175,7 +188,6 @@ class OrderListAdapter( viewBinding.orderItemLayout.setBackgroundColor(Color.TRANSPARENT) } } - // clear existing tags and add new ones viewBinding.orderTags.removeAllViews() processTagView(orderItemUI.status, this) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListFragment.kt index 463ecd55b49..0691d8b516c 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListFragment.kt @@ -10,6 +10,8 @@ import android.view.MenuItem.OnActionExpandListener import android.view.View import android.view.ViewGroup import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView.OnQueryTextListener import androidx.core.view.ViewGroupCompat @@ -21,6 +23,8 @@ import androidx.navigation.findNavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.findNavController import androidx.paging.PagedList +import androidx.recyclerview.selection.SelectionTracker +import androidx.recyclerview.selection.StorageStrategy import androidx.recyclerview.widget.ItemTouchHelper import androidx.transition.TransitionManager import com.google.android.material.floatingactionbutton.FloatingActionButton @@ -58,6 +62,8 @@ import com.woocommerce.android.ui.jitm.JitmMessagePathsProvider import com.woocommerce.android.ui.main.AppBarStatus import com.woocommerce.android.ui.main.MainActivity import com.woocommerce.android.ui.main.MainNavigationRouter +import com.woocommerce.android.ui.orders.DefaultOrderListItemLookup +import com.woocommerce.android.ui.orders.OrderSelectionItemKeyProvider import com.woocommerce.android.ui.orders.OrderStatusUpdateSource import com.woocommerce.android.ui.orders.OrdersCommunicationViewModel import com.woocommerce.android.ui.orders.creation.CodeScannerStatus @@ -65,8 +71,11 @@ import com.woocommerce.android.ui.orders.creation.GoogleBarcodeFormatMapper.Barc import com.woocommerce.android.ui.orders.creation.OrderCreateEditViewModel import com.woocommerce.android.ui.orders.list.OrderListViewModel.OrderListEvent.ShowErrorSnack import com.woocommerce.android.ui.orders.list.OrderListViewModel.OrderListEvent.ShowOrderFilters +import com.woocommerce.android.ui.products.MutableMultipleSelectionPredicate import com.woocommerce.android.util.ChromeCustomTabUtils import com.woocommerce.android.util.CurrencyFormatter +import com.woocommerce.android.util.FeatureFlag +import com.woocommerce.android.util.StringUtils import com.woocommerce.android.viewmodel.MultiLiveEvent import com.woocommerce.android.widgets.WCEmptyView.EmptyViewType import dagger.hilt.android.AndroidEntryPoint @@ -82,7 +91,8 @@ class OrderListFragment : OnQueryTextListener, OnActionExpandListener, OrderListListener, - SwipeToComplete.OnSwipeListener { + SwipeToComplete.OnSwipeListener, + ActionMode.Callback { companion object { const val TAG: String = "OrderListFragment" const val STATE_KEY_SEARCH_QUERY = "search-query" @@ -108,6 +118,9 @@ class OrderListFragment : @Inject lateinit var feedbackPrefs: FeedbackPrefs + private var tracker: SelectionTracker? = null + private var actionMode: ActionMode? = null + private val selectionPredicate = MutableMultipleSelectionPredicate() private val viewModel: OrderListViewModel by viewModels() private val communicationViewModel: OrdersCommunicationViewModel by activityViewModels() private var snackBar: Snackbar? = null @@ -239,6 +252,34 @@ class OrderListFragment : binding.orderFiltersCard.setClickListener { viewModel.onFiltersButtonTapped() } initCreateOrderFAB(binding.createOrderButton) initSwipeBehaviour() + + if (FeatureFlag.BULK_UPDATE_ORDERS_STATUS.isEnabled()) { + addSelectionTracker() + } + } + + private fun addSelectionTracker() { + tracker = SelectionTracker.Builder( + "orderSelection", // a string to identify our selection in the context of this fragment + binding.orderListView.ordersList, // the RecyclerView where we will apply the tracker + OrderSelectionItemKeyProvider(binding.orderListView.ordersList), // the source of selection keys + DefaultOrderListItemLookup( + binding.orderListView.ordersList + ), // the source of information about recycler items + StorageStrategy.createLongStorage() // strategy for type-safe storage of the selection state + ).withSelectionPredicate(selectionPredicate) + .build() + + (binding.orderListView.ordersList.adapter as? OrderListAdapter)?.tracker = tracker + + tracker?.addObserver( + object : SelectionTracker.SelectionObserver() { + override fun onSelectionChanged() { + val selectionCount = tracker?.selection?.size() ?: 0 + viewModel.onSelectionChanged(selectionCount) + } + } + ) } private fun setupToolbar() { @@ -647,12 +688,44 @@ class OrderListFragment : new.shouldDisplayTroubleshootingBanner.takeIfNotEqualTo(old?.shouldDisplayTroubleshootingBanner) { displayTimeoutErrorCard(it) } + new.orderListState?.takeIfNotEqualTo(old?.orderListState) { + handleListState(it) + } + new.selectionCount?.takeIfNotEqualTo(old?.selectionCount) { count -> + actionMode?.title = StringUtils.getQuantityString( + context = requireContext(), + quantity = count, + default = R.string.orderlist_selection_count, + one = R.string.orderlist_selection_count_single + ) + } } viewModel.lastUpdateOrdersList.observe(viewLifecycleOwner) { lastUpdate -> binding.orderFiltersCard.updateLastUpdate(lastUpdate) } } + private fun handleListState(orderListState: OrderListViewModel.ViewState.OrderListState) { + when (orderListState) { + OrderListViewModel.ViewState.OrderListState.Selecting -> { + actionMode = (requireActivity() as AppCompatActivity) + .startSupportActionMode(this@OrderListFragment) + delayMultiSelection() + } + + OrderListViewModel.ViewState.OrderListState.Browsing -> { + actionMode?.finish() + } + } + } + + private fun delayMultiSelection() { + selectionPredicate.selectMultiple = false + binding.orderListView.ordersList.post { + selectionPredicate.selectMultiple = true + } + } + private fun openFirstOrder() { binding.orderListView.openFirstOrder() } @@ -1046,4 +1119,27 @@ class OrderListFragment : extraTags = ArrayList() ).let { activity?.startActivity(it) } } + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.menu_action_mode_orders_list, menu) + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu) = false + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_orderlist_update_status -> { + // todo: implement bulk update status + true + } + + else -> false + } + } + + override fun onDestroyActionMode(mode: ActionMode) { + tracker?.clearSelection() + actionMode = null + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListViewModel.kt index f36f24e9279..56d3406ccea 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/orders/list/OrderListViewModel.kt @@ -211,6 +211,8 @@ class OrderListViewModel @Inject constructor( ) }.asLiveData() + fun isSelecting() = viewState.orderListState == ViewState.OrderListState.Selecting + init { lifecycleRegistry.currentState = Lifecycle.State.CREATED lifecycleRegistry.currentState = Lifecycle.State.STARTED @@ -887,6 +889,28 @@ class OrderListViewModel @Inject constructor( ) } + fun onSelectionChanged(count: Int) { + when { + count == 0 -> exitSelectionMode() + count > 0 && !isSelecting() -> enterSelectionMode(count) + count > 0 -> viewState = viewState.copy(selectionCount = count) + } + } + + private fun enterSelectionMode(count: Int) { + viewState = viewState.copy( + orderListState = ViewState.OrderListState.Selecting, + selectionCount = count + ) + } + + private fun exitSelectionMode() { + viewState = viewState.copy( + orderListState = ViewState.OrderListState.Browsing, + selectionCount = null + ) + } + sealed class OrderListEvent : Event() { data class ShowErrorSnack(@StringRes val messageRes: Int) : OrderListEvent() object ShowOrderFilters : OrderListEvent() @@ -931,10 +955,14 @@ class OrderListViewModel @Inject constructor( val isSimplePaymentsAndOrderCreationFeedbackVisible: Boolean = false, val jitmEnabled: Boolean = false, val isErrorFetchingDataBannerVisible: Boolean = false, - val shouldDisplayTroubleshootingBanner: Boolean = false + val shouldDisplayTroubleshootingBanner: Boolean = false, + val orderListState: OrderListState? = null, + val selectionCount: Int? = null ) : Parcelable { @IgnoredOnParcel val isFilteringActive = filterCount > 0 + + enum class OrderListState { Selecting, Browsing } } enum class Mode { diff --git a/WooCommerce/src/main/res/menu/menu_action_mode_orders_list.xml b/WooCommerce/src/main/res/menu/menu_action_mode_orders_list.xml new file mode 100644 index 00000000000..8e7ed21acc4 --- /dev/null +++ b/WooCommerce/src/main/res/menu/menu_action_mode_orders_list.xml @@ -0,0 +1,8 @@ + + + + diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 467a5f99742..f4648c0bf3f 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -602,6 +602,9 @@ If your data still isn\'t loading, contact our support team for assistance. Order trashed Error trashing order + %d orders selected + %d order selected + Update status