diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeToSelectTouchListener.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeToSelectTouchListener.kt new file mode 100644 index 00000000000..385e2a6bc8a --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeToSelectTouchListener.kt @@ -0,0 +1,117 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.mail.ui.main.folder + +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.recyclerview.widget.RecyclerView +import com.infomaniak.mail.data.models.thread.Thread +import kotlin.math.abs + +class SwipeToSelectTouchListener( + recyclerView: RecyclerView, + private val multiSelection: MultiSelectionListener, +) : RecyclerView.OnItemTouchListener { + + private val touchSlop = ViewConfiguration.get(recyclerView.context).scaledTouchSlop + private var isActive = false + private var startX = 0f + private var startY = 0f + private var startPosition = RecyclerView.NO_POSITION + private var lastSelectedPosition = RecyclerView.NO_POSITION + private var selectionIntent: Boolean? = null // true = select, false = deselect + + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + when (e.actionMasked) { + MotionEvent.ACTION_DOWN -> { + startX = e.x + startY = e.y + startPosition = rv.findChildViewUnder(startX, startY) + ?.let(rv::getChildAdapterPosition) + ?: RecyclerView.NO_POSITION + lastSelectedPosition = RecyclerView.NO_POSITION + selectionIntent = null + isActive = false + } + MotionEvent.ACTION_MOVE -> { + if (!isActive && multiSelection.isEnabled) { + val dy = abs(e.y - startY) + val dx = abs(e.x - startX) + if (dy > touchSlop && dy > dx) { + val startThread = (rv.adapter as? ThreadListAdapter)?.getThreadAt(startPosition) + selectionIntent = startThread != null && !multiSelection.selectedItems.contains(startThread) + + if (startThread != null) { + applyIntent(startThread) + multiSelection.publishSelectedItems() + rv.adapter?.notifyItemChanged(startPosition, ThreadListAdapter.NotificationType.SELECTED_STATE) + lastSelectedPosition = startPosition + } + + isActive = true + return true + } + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> reset() + } + return isActive + } + + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { + when (e.actionMasked) { + MotionEvent.ACTION_MOVE -> { + if (!isActive) return + val view = rv.findChildViewUnder(e.x, e.y) ?: return + val position = rv.getChildAdapterPosition(view) + if (position == RecyclerView.NO_POSITION || position == lastSelectedPosition) return + + val thread = (rv.adapter as? ThreadListAdapter)?.getThreadAt(position) ?: return + applyIntent(thread) + multiSelection.publishSelectedItems() + rv.adapter?.notifyItemChanged(position, ThreadListAdapter.NotificationType.SELECTED_STATE) + rv.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + lastSelectedPosition = position + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> reset() + } + } + + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + if (disallowIntercept) reset() + } + + private fun applyIntent(thread: Thread) { + val intent = selectionIntent ?: return + if (intent) { + multiSelection.selectedItems.add(thread) + } else { + multiSelection.selectedItems.remove(thread) + } + } + + private fun reset() { + isActive = false + startX = 0f + startY = 0f + startPosition = RecyclerView.NO_POSITION + lastSelectedPosition = RecyclerView.NO_POSITION + selectionIntent = null + } +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt index 441434df1d8..b89f1cca1d8 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt @@ -181,6 +181,10 @@ class ThreadListAdapter @Inject constructor( .takeIf { position -> position != -1 } } + fun getThreadAt(position: Int): Thread? { + return (dataSet.getOrNull(position) as? ThreadListItem.Content)?.thread + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThreadListViewHolder { val layoutInflater = LayoutInflater.from(parent.context) val binding = when (viewType) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt index 8061efb4f7e..6ed30d0d909 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt @@ -410,6 +410,17 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { disableDragDirection(DirectionFlag.LEFT) addStickyDateDecoration(threadListAdapter, localSettings.threadDensity) } + + binding.threadsList.addOnItemTouchListener( + SwipeToSelectTouchListener( + recyclerView = binding.threadsList, + multiSelection = object : MultiSelectionListener { + override var isEnabled by mainViewModel::isMultiSelectOn + override val selectedItems by mainViewModel::selectedThreads + override val publishSelectedItems = mainViewModel::publishSelectedItems + }, + ) + ) } private fun updateDisabledSwipeActionsUi(featureFlags: FeatureFlagSet?, folderRole: FolderRole?) {