From 58568624e9993d02f2030ac00b0cd6a848bbb5bd Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 30 Jan 2026 08:13:57 +0100 Subject: [PATCH 01/52] feat: Refactor of actions to use messages instead of threads --- .../ui/main/thread/actions/MessageActionsBottomSheetDialog.kt | 4 ++++ .../main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt | 1 + 2 files changed, 5 insertions(+) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt index a13e8400de..0d04983995 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt @@ -55,6 +55,7 @@ import com.infomaniak.mail.utils.extensions.navigateToDownloadMessagesProgressDi import com.infomaniak.mail.utils.extensions.replyWithConfirmationPopup import com.infomaniak.mail.utils.extensions.safeNavigateToNewMessageActivity import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject import com.infomaniak.core.common.R as RCore @@ -75,6 +76,9 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { @Inject lateinit var descriptionDialog: DescriptionAlertDialog + @Inject + lateinit var globalCoroutineScope: CoroutineScope + @Inject lateinit var folderRoleUtils: FolderRoleUtils diff --git a/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt index 41e76800e0..46702ed3ad 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt @@ -18,6 +18,7 @@ package com.infomaniak.mail.utils import com.infomaniak.mail.data.cache.mailboxContent.FolderController +import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.Snoozable import com.infomaniak.mail.data.models.isSnoozed From 70fadc0edbd56466b866ef12932cd528dde56012 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Mon, 2 Feb 2026 10:48:50 +0100 Subject: [PATCH 02/52] refactor: Remove unnused variables, add parameters name --- .../ui/main/thread/actions/MessageActionsBottomSheetDialog.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt index 0d04983995..a13e8400de 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt @@ -55,7 +55,6 @@ import com.infomaniak.mail.utils.extensions.navigateToDownloadMessagesProgressDi import com.infomaniak.mail.utils.extensions.replyWithConfirmationPopup import com.infomaniak.mail.utils.extensions.safeNavigateToNewMessageActivity import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject import com.infomaniak.core.common.R as RCore @@ -76,9 +75,6 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { @Inject lateinit var descriptionDialog: DescriptionAlertDialog - @Inject - lateinit var globalCoroutineScope: CoroutineScope - @Inject lateinit var folderRoleUtils: FolderRoleUtils From 3fac308be68677c85a300e1963ae425d35ab33c9 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Tue, 17 Feb 2026 13:02:19 +0100 Subject: [PATCH 03/52] refactor: Separate emoji functionality --- .../ui/main/thread/actions/ActionsViewModel.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index fa1f0a5fab..c993b07775 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -676,6 +676,7 @@ class ActionsViewModel @Inject constructor( } } + fun List>.getFailedCall() = firstOrNull { it.data != true } fun modifyScheduledDraft( unscheduleDraftUrl: String, onSuccess: () -> Unit, @@ -688,6 +689,7 @@ class ActionsViewModel @Inject constructor( return@launch } + val (resources, foldersIds, destinationFolderId) = undoData refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(draftFolder.id))) onSuccess() } else { @@ -695,6 +697,7 @@ class ActionsViewModel @Inject constructor( } } + val apiResponses = resources.map { ApiRepository.undoAction(it) } fun unscheduleDraft(unscheduleDraftUrl: String, mailbox: Mailbox, openFolder: (folderId: String) -> Unit) = viewModelScope.launch(ioCoroutineContext) { val apiResponse = messagesActions.unscheduleDraft(unscheduleDraftUrl) @@ -704,6 +707,12 @@ class ActionsViewModel @Inject constructor( return@launch } + if (apiResponses.atLeastOneSucceeded()) { + // Don't use `refreshFoldersAsync` here, it will make the Snackbars blink. + sharedUtils.refreshFolders( + mailbox = mailbox, + messagesFoldersIds = foldersIds, + destinationFolderId = destinationFolderId, refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(scheduledDraftsFolder.id))) } @@ -728,8 +737,12 @@ class ActionsViewModel @Inject constructor( } } + val failedCall = apiResponses.getFailedCall() //region Delete + val snackbarTitle = when { + failedCall == null -> R.string.snackbarMoveCancelled + else -> failedCall.translateError() fun deleteDraft(targetMailboxUuid: String, remoteDraftUuid: String, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { val apiResponse = messagesActions.deleteDraft(targetMailboxUuid, remoteDraftUuid) @@ -746,12 +759,16 @@ class ActionsViewModel @Inject constructor( showDeletedDraftSnackbar(apiResponse) } + snackbarManager.postValue(appContext.getString(snackbarTitle)) private fun showDeletedDraftSnackbar(apiResponse: ApiResponse) { val titleRes = if (apiResponse.isSuccess()) R.string.snackbarDraftDeleted else apiResponse.translateError() snackbarManager.postValue(appContext.getString(titleRes)) } //endregion + private fun onDownloadStart() { + isDownloadingChanges.postValue(true) + } //region Undo action fun undoAction(undoData: UndoData?, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { if (undoData == null) return@launch From 054f2af697a231eeba5233b2b302464bfc60494d Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 7 May 2026 15:43:25 +0200 Subject: [PATCH 04/52] fix: FolderRolesActions to be able to delete properly schedule messages and show dialog --- .../main/thread/actions/ActionsViewModel.kt | 29 ++++--------------- .../mail/useCases/MessagesActions.kt | 4 +++ .../infomaniak/mail/utils/FolderRoleUtils.kt | 1 - 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index c993b07775..e722444bb8 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -201,7 +201,8 @@ class ActionsViewModel @Inject constructor( mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { val messages = messageController.getMessages(messagesUids) - handleMessagesMove(destinationFolderId, messages, currentFolderId, mailbox) + val messagesToMove = messagesActions.getMessagesToMove(messages, currentFolderId) + handleMessagesMove(destinationFolderId, messagesToMove, currentFolderId, mailbox) } private suspend fun handleMessagesMove( @@ -328,11 +329,7 @@ class ActionsViewModel @Inject constructor( // If deleteMessages is empty we will do the auto advance after deleting permanently if (onlyPermanentlyDeleteMessages) calculateCurrentThreadPosition.postValue(Unit) handlePermanentlyDeleteMessages( - permanentlyDeleteMessages = permanentlyDeleteMessages, - mailbox = mailbox, - currentFolder = currentFolder, - shouldAutoAdvanceAndRefresh = onlyPermanentlyDeleteMessages, - messagesToDelete = messagesToDelete + permanentlyDeleteMessages, mailbox, currentFolder, onlyPermanentlyDeleteMessages, messagesToDelete ) } @@ -438,7 +435,8 @@ class ActionsViewModel @Inject constructor( currentFolder: Folder?, mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - handleArchiveMessages(messages, currentFolder, mailbox) + val messagesToMove = messagesActions.getMessagesToMove(messages, currentFolder?.id) + handleArchiveMessages(messagesToMove, currentFolder, mailbox) } private suspend fun handleArchiveMessages( @@ -676,7 +674,6 @@ class ActionsViewModel @Inject constructor( } } - fun List>.getFailedCall() = firstOrNull { it.data != true } fun modifyScheduledDraft( unscheduleDraftUrl: String, onSuccess: () -> Unit, @@ -689,7 +686,6 @@ class ActionsViewModel @Inject constructor( return@launch } - val (resources, foldersIds, destinationFolderId) = undoData refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(draftFolder.id))) onSuccess() } else { @@ -697,7 +693,6 @@ class ActionsViewModel @Inject constructor( } } - val apiResponses = resources.map { ApiRepository.undoAction(it) } fun unscheduleDraft(unscheduleDraftUrl: String, mailbox: Mailbox, openFolder: (folderId: String) -> Unit) = viewModelScope.launch(ioCoroutineContext) { val apiResponse = messagesActions.unscheduleDraft(unscheduleDraftUrl) @@ -707,12 +702,6 @@ class ActionsViewModel @Inject constructor( return@launch } - if (apiResponses.atLeastOneSucceeded()) { - // Don't use `refreshFoldersAsync` here, it will make the Snackbars blink. - sharedUtils.refreshFolders( - mailbox = mailbox, - messagesFoldersIds = foldersIds, - destinationFolderId = destinationFolderId, refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(scheduledDraftsFolder.id))) } @@ -737,12 +726,8 @@ class ActionsViewModel @Inject constructor( } } - val failedCall = apiResponses.getFailedCall() //region Delete - val snackbarTitle = when { - failedCall == null -> R.string.snackbarMoveCancelled - else -> failedCall.translateError() fun deleteDraft(targetMailboxUuid: String, remoteDraftUuid: String, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { val apiResponse = messagesActions.deleteDraft(targetMailboxUuid, remoteDraftUuid) @@ -759,16 +744,12 @@ class ActionsViewModel @Inject constructor( showDeletedDraftSnackbar(apiResponse) } - snackbarManager.postValue(appContext.getString(snackbarTitle)) private fun showDeletedDraftSnackbar(apiResponse: ApiResponse) { val titleRes = if (apiResponse.isSuccess()) R.string.snackbarDraftDeleted else apiResponse.translateError() snackbarManager.postValue(appContext.getString(titleRes)) } //endregion - private fun onDownloadStart() { - isDownloadingChanges.postValue(true) - } //region Undo action fun undoAction(undoData: UndoData?, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { if (undoData == null) return@launch diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt index 05f77358ff..ea685df60a 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt @@ -91,6 +91,10 @@ class MessagesActions @Inject constructor( return threads.flatMap { messageController.getMovableMessages(it) } } + fun getMessagesToMove(messages: List, currentFolderId: String?): List { + return messages.filter { message -> message.folderId == currentFolderId && !message.isScheduledMessage } + } + private suspend fun moveMessages( mailbox: Mailbox, messagesToMove: List, diff --git a/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt index 46702ed3ad..41e76800e0 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt @@ -18,7 +18,6 @@ package com.infomaniak.mail.utils import com.infomaniak.mail.data.cache.mailboxContent.FolderController -import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.Snoozable import com.infomaniak.mail.data.models.isSnoozed From 0e5b288fbbc7f3e2fcefabb646449949ad32605b Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 20 Feb 2026 08:38:02 +0100 Subject: [PATCH 05/52] refactor: Remove messages related functions from SharedUtils --- .../java/com/infomaniak/mail/useCases/MessagesActions.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt index ea685df60a..80d1e74d3d 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt @@ -88,7 +88,13 @@ class MessagesActions @Inject constructor( } suspend fun getMessagesFromThreadsToMove(threads: List): List { - return threads.flatMap { messageController.getMovableMessages(it) } + return threads.flatMap { + messageController.getMovableMessages(it) + } + } + + fun getMessagesToMove(messages: List, currentFolderId: String?): List { + return messages.filter { message -> message.folderId == currentFolderId && !message.isScheduledMessage } } fun getMessagesToMove(messages: List, currentFolderId: String?): List { From c8da3787f9d5a4a6e1d409457367ee5f0fe0fde3 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Wed, 21 Jan 2026 10:05:41 +0100 Subject: [PATCH 06/52] feat: Add multiselection interface # Conflicts: # app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt # app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt --- .../mail/ui/main/folder/ThreadListFragment.kt | 55 ++++++++-- .../main/folder/ThreadListMultiSelection.kt | 101 ++++++++++++------ .../mail/ui/main/folder/TwoPaneFragment.kt | 2 +- .../mail/ui/main/search/SearchFragment.kt | 82 +++++++++++++- app/src/main/res/layout/fragment_search.xml | 13 +++ .../main/res/layout/fragment_thread_list.xml | 62 +++-------- .../view_multiselection_info_toolbar.xml | 61 +++++++++++ .../main/res/navigation/main_navigation.xml | 13 ++- 8 files changed, 290 insertions(+), 99 deletions(-) create mode 100644 app/src/main/res/layout/view_multiselection_info_toolbar.xml 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 49d2039c26..778335a07b 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 @@ -41,10 +41,12 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle.State import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.NavDirections import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy import com.infomaniak.core.common.extensions.goToAppStore + import com.infomaniak.core.common.observe import com.infomaniak.core.common.utils.isToday import com.infomaniak.core.inappupdate.updatemanagers.InAppUpdateManager @@ -125,7 +127,7 @@ import com.infomaniak.core.legacy.R as RCore import com.infomaniak.core.legacy.utils.Utils as UtilsCore @AndroidEntryPoint -class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { +class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectionHost { private var _binding: FragmentThreadListBinding? = null val binding get() = _binding!! // This property is only valid between onCreateView and onDestroyView @@ -148,13 +150,20 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { private var isFirstTimeRefreshingThreads = true @Inject - lateinit var descriptionDialog: DescriptionAlertDialog + override lateinit var descriptionDialog: DescriptionAlertDialog - @Inject - lateinit var downloadThreadsStatusManager: DownloadThreadsStatusManager + override fun safeNavigation(directions: NavDirections) { + safeNavigate(directions) + } + + override fun disableSwipeDirection(direction: DirectionFlag) { + binding.threadsList.disableSwipeDirection(direction) + } + + override lateinit var folderRoleUtils: FolderRoleUtils @Inject - lateinit var folderRoleUtils: FolderRoleUtils + lateinit var downloadThreadsStatusManager: DownloadThreadsStatusManager @Inject lateinit var inAppUpdateManager: InAppUpdateManager @@ -195,7 +204,8 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { threadListMultiSelection.initMultiSelection( mainViewModel = mainViewModel, actionsViewModel = actionsViewModel, - threadListFragment = this, + activity = (requireActivity() as MainActivity), + host = this, unlockSwipeActionsIfSet = ::unlockSwipeActionsIfSet, localSettings = localSettings, ) @@ -235,6 +245,17 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { navigateFromNotificationToThread() } + override val multiSelectionBinding: MultiSelectionBinding + get() = object : MultiSelectionBinding { + override val quickActionBar get() = binding.quickActionBar + override val multiselectToolbar get() = binding.multiselectToolbar + override val toolbarLayout get() = binding.toolbarLayout + override val toolbar get() = binding.toolbar + override val threadsList get() = binding.threadsList + override val newMessageFab get() = binding.newMessageFab + override val unreadCountChip get() = binding.unreadCountChip + } + private fun handleEdgeToEdge() = with(binding) { // Since threadFragment is in this view, we also share the inset with it, so that we can manage the edgeToEdge applyWindowInsetsListener(shouldConsume = false) { _, insets -> @@ -332,7 +353,7 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { _binding = null } - private fun unlockSwipeActionsIfSet() = with(binding.threadsList) { + override fun unlockSwipeActionsIfSet() = with(binding.threadsList) { val isMultiSelectClosed = mainViewModel.isMultiSelectOn.not() val isLeftSet = localSettings.swipeLeft != SwipeAction.NONE @@ -344,6 +365,22 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { if (isRightEnabled) enableSwipeDirection(DirectionFlag.RIGHT) else disableSwipeDirection(DirectionFlag.RIGHT) } + override fun directionToThreadActionsBottomSheetDialog( + threadUid: String, + shouldLoadDistantResources: Boolean, + shouldCloseMultiSelection: Boolean + ): NavDirections { + return ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( + threadUid, + shouldLoadDistantResources, + shouldCloseMultiSelection + ) + } + + override fun directionsToMultiSelectBottomSheetDialog(): NavDirections { + return ThreadListFragmentDirections.actionThreadListFragmentToMultiSelectBottomSheetDialog() + } + private fun setupDensityDependentUi() = with(binding) { val paddingTop = resources.getDimension(RCore.dimen.marginStandardMedium).toInt() threadsList.setPaddingRelative(top = if (localSettings.threadDensity == COMPACT) paddingTop else 0) @@ -451,11 +488,11 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { (requireActivity() as MainActivity).openDrawerLayout() } - cancel.setOnClickListener { + multiselectToolbar.cancel.setOnClickListener { trackMultiSelectionEvent(MatomoName.Cancel) mainViewModel.isMultiSelectOn = false } - selectAll.setOnClickListener { + multiselectToolbar.selectAll.setOnClickListener { mainViewModel.selectOrUnselectAll() threadListAdapter.updateSelection() } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index 31bf5cd211..e8438f9329 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -21,8 +21,9 @@ import android.transition.AutoTransition import android.transition.TransitionManager import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope -import com.infomaniak.core.fragmentnavigation.safelyNavigate +import androidx.navigation.NavDirections import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation.DirectionFlag import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackMultiSelectActionEvent @@ -39,11 +40,43 @@ import com.infomaniak.mail.utils.extensions.archiveWithConfirmationPopup import com.infomaniak.mail.utils.extensions.deleteWithConfirmationPopup import kotlinx.coroutines.launch +interface MultiSelectionHost : LifecycleOwner { + val multiSelectionBinding: MultiSelectionBinding + val folderRoleUtils: com.infomaniak.mail.utils.FolderRoleUtils + val descriptionDialog: com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog + val threadListAdapter: ThreadListAdapter + fun safeNavigation(directions: NavDirections) + fun disableSwipeDirection(direction: DirectionFlag) + fun unlockSwipeActionsIfSet() + fun directionToThreadActionsBottomSheetDialog( + threadUid: String, + shouldLoadDistantResources: Boolean, + shouldCloseMultiSelection: Boolean + ): NavDirections + + fun directionsToMultiSelectBottomSheetDialog(): NavDirections +} + +interface MultiSelectionBinding { + val quickActionBar: com.infomaniak.mail.views.BottomQuickActionBarView + val multiselectToolbar: com.infomaniak.mail.databinding.ViewMultiselectionInfoToolbarBinding + val toolbarLayout: android.view.View + val toolbar: android.view.View + val threadsList: android.view.ViewGroup + val newMessageFab: android.view.View? + val unreadCountChip: android.view.View? +} + class ThreadListMultiSelection { lateinit var mainViewModel: MainViewModel + lateinit var actionsViewModel: ActionsViewModel private lateinit var threadListFragment: ThreadListFragment + + lateinit var mainActivity: MainActivity + private lateinit var host: MultiSelectionHost + lateinit var unlockSwipeActionsIfSet: () -> Unit lateinit var localSettings: LocalSettings @@ -53,13 +86,15 @@ class ThreadListMultiSelection { fun initMultiSelection( mainViewModel: MainViewModel, actionsViewModel: ActionsViewModel, - threadListFragment: ThreadListFragment, + activity: MainActivity, + host: MultiSelectionHost, unlockSwipeActionsIfSet: () -> Unit, localSettings: LocalSettings, ) { this.mainViewModel = mainViewModel this.actionsViewModel = actionsViewModel - this.threadListFragment = threadListFragment + this.mainActivity = activity + this.host = host this.unlockSwipeActionsIfSet = unlockSwipeActionsIfSet this.localSettings = localSettings @@ -69,7 +104,7 @@ class ThreadListMultiSelection { } private fun setupMultiSelectionActions() = with(mainViewModel) { - threadListFragment.binding.quickActionBar.setOnItemClickListener { menuId -> + host.multiSelectionBinding.quickActionBar.setOnItemClickListener { menuId -> val selectedThreadsUids = selectedThreads.map { it.uid } val selectedThreadsCount = selectedThreadsUids.count() val currentMailBox = currentMailbox.value ?: return@setOnItemClickListener @@ -85,9 +120,9 @@ class ThreadListMultiSelection { ) isMultiSelectOn = false } - R.id.quickActionArchive -> threadListFragment.lifecycleScope.launch { - threadListFragment.descriptionDialog.archiveWithConfirmationPopup( - folderRole = threadListFragment.folderRoleUtils.getThreadsActionFolderRole(selectedThreads), + R.id.quickActionArchive -> host.lifecycleScope.launch { + host.descriptionDialog.archiveWithConfirmationPopup( + folderRole = host.folderRoleUtils.getThreadsActionFolderRole(selectedThreads), count = selectedThreadsCount, ) { trackMultiSelectActionEvent(MatomoName.Archive, selectedThreadsCount) @@ -108,10 +143,10 @@ class ThreadListMultiSelection { ) isMultiSelectOn = false } - R.id.quickActionDelete -> threadListFragment.lifecycleScope.launch { + R.id.quickActionDelete -> host.lifecycleScope.launch { val allMessages = selectedThreads.flatMap { it.messages } - threadListFragment.descriptionDialog.deleteWithConfirmationPopup( - messagesFolderRoles = threadListFragment.folderRoleUtils.getActionFolderRoles(allMessages), + host.descriptionDialog.deleteWithConfirmationPopup( + messagesFolderRoles = host.folderRoleUtils.getActionFolderRoles(allMessages), currentFolderRole = currentFolder.value?.role, count = selectedThreadsCount, ) { @@ -124,33 +159,33 @@ class ThreadListMultiSelection { trackMultiSelectActionEvent(MatomoName.OpenBottomSheet, selectedThreadsCount) val direction = if (selectedThreadsCount == 1) { isMultiSelectOn = false - ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( + host.directionToThreadActionsBottomSheetDialog( threadUid = selectedThreadsUids.single(), shouldLoadDistantResources = false, shouldCloseMultiSelection = true, ) } else { - ThreadListFragmentDirections.actionThreadListFragmentToMultiSelectBottomSheetDialog() + host.directionsToMultiSelectBottomSheetDialog() } - threadListFragment.safelyNavigate(direction) + host.safeNavigation(direction) } } } } - private fun observerMultiSelection() = with(threadListFragment) { - mainViewModel.isMultiSelectOnLiveData.observe(viewLifecycleOwner) { isMultiSelectOn -> + private fun observerMultiSelection() = with(host) { + mainViewModel.isMultiSelectOnLiveData.observe(host) { isMultiSelectOn -> threadListAdapter.updateSelection() - if (localSettings.threadDensity != ThreadDensity.LARGE) TransitionManager.beginDelayedTransition(binding.threadsList) + if (localSettings.threadDensity != ThreadDensity.LARGE) TransitionManager.beginDelayedTransition(host.multiSelectionBinding.threadsList) if (!isMultiSelectOn) mainViewModel.selectedThreads.clear() displaySelectionToolbar(isMultiSelectOn) lockDrawerAndSwipe(isMultiSelectOn) - hideUnreadChip(isMultiSelectOn) + if (multiSelectionBinding.unreadCountChip != null) hideUnreadChip(isMultiSelectOn) displayMultiSelectActions(isMultiSelectOn) } - mainViewModel.selectedThreadsLiveData.observe(viewLifecycleOwner) { selectedThreads -> + mainViewModel.selectedThreadsLiveData.observe(host) { selectedThreads -> if (selectedThreads.isEmpty()) { mainViewModel.isMultiSelectOn = false } else { @@ -161,19 +196,19 @@ class ThreadListMultiSelection { } } - private fun displaySelectionToolbar(isMultiSelectOn: Boolean) = with(threadListFragment.binding) { + private fun displaySelectionToolbar(isMultiSelectOn: Boolean) = with(host.multiSelectionBinding) { val autoTransition = AutoTransition() autoTransition.duration = TOOLBAR_FADE_DURATION - TransitionManager.beginDelayedTransition(toolbarLayout, autoTransition) + TransitionManager.beginDelayedTransition(multiselectToolbar.toolbar, autoTransition) toolbar.isGone = isMultiSelectOn - toolbarSelection.isVisible = isMultiSelectOn + multiselectToolbar.toolbar.isVisible = isMultiSelectOn } - private fun lockDrawerAndSwipe(isMultiSelectOn: Boolean) = with(threadListFragment) { - (requireActivity() as MainActivity).setDrawerLockMode(isLocked = isMultiSelectOn) + private fun lockDrawerAndSwipe(isMultiSelectOn: Boolean) = with(host) { + mainActivity.setDrawerLockMode(isLocked = isMultiSelectOn) if (isMultiSelectOn) { - binding.threadsList.apply { + multiSelectionBinding.threadsList.apply { disableSwipeDirection(DirectionFlag.LEFT) disableSwipeDirection(DirectionFlag.RIGHT) } @@ -184,17 +219,17 @@ class ThreadListMultiSelection { private fun hideUnreadChip(isMultiSelectOn: Boolean) = runCatchingRealm { val thereAreUnread = mainViewModel.currentFolderLive.value?.let { it.unreadCountLocal > 0 } == true - threadListFragment.binding.unreadCountChip.isVisible = thereAreUnread && !isMultiSelectOn + host.multiSelectionBinding.unreadCountChip?.isVisible = thereAreUnread && !isMultiSelectOn } - private fun displayMultiSelectActions(isMultiSelectOn: Boolean) = with(threadListFragment.binding) { - newMessageFab.isGone = isMultiSelectOn + private fun displayMultiSelectActions(isMultiSelectOn: Boolean) = with(host.multiSelectionBinding) { + newMessageFab?.let { it.isGone = isMultiSelectOn } quickActionBar.isVisible = isMultiSelectOn } private fun updateSelectedCount(selectedThreads: Set) { val threadCount = selectedThreads.count() - threadListFragment.binding.selectedCount.text = threadListFragment.resources.getQuantityString( + host.multiSelectionBinding.multiselectToolbar.selectedCount.text = mainActivity.resources.getQuantityString( R.plurals.multipleSelectionCount, threadCount, threadCount @@ -203,7 +238,7 @@ class ThreadListMultiSelection { private fun updateSelectAllLabel() { val selectAllLabel = if (mainViewModel.isEverythingSelected) R.string.buttonUnselectAll else R.string.buttonSelectAll - threadListFragment.binding.selectAll.setText(selectAllLabel) + host.multiSelectionBinding.multiselectToolbar.selectAll.setText(selectAllLabel) } private fun updateMultiSelectActionsStatus(selectedThreads: Set) { @@ -212,7 +247,7 @@ class ThreadListMultiSelection { shouldMultiselectFavorite = shouldFavorite } - threadListFragment.binding.quickActionBar.apply { + host.multiSelectionBinding.quickActionBar.apply { val (readIcon, readText) = getReadIconAndShortText(shouldMultiselectRead) changeIcon(READ_UNREAD_INDEX, readIcon) changeText(READ_UNREAD_INDEX, readText) @@ -221,9 +256,9 @@ class ThreadListMultiSelection { changeIcon(FAVORITE_INDEX, favoriteIcon) val isSelectionEmpty = selectedThreads.isEmpty() - threadListFragment.viewLifecycleOwner.lifecycleScope.launch { - val isFromArchive = - threadListFragment.folderRoleUtils.getThreadsActionFolderRole(selectedThreads) == FolderRole.ARCHIVE + + host.lifecycleScope.launch { + val isFromArchive = host.folderRoleUtils.getThreadsActionFolderRole(selectedThreads) == FolderRole.ARCHIVE for (index in 0 until getButtonCount()) { val shouldDisable = isSelectionEmpty || (isFromArchive && index == ARCHIVE_INDEX) if (shouldDisable) disable(index) else enable(index) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt index f3612b2298..dedb4c3041 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt @@ -75,7 +75,7 @@ abstract class TwoPaneFragment : Fragment() { lateinit var threadListAdapter: ThreadListAdapter @Inject - lateinit var localSettings: LocalSettings + open lateinit var localSettings: LocalSettings abstract val substituteClassName: String abstract fun getLeftPane(): View? diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index fae78eb8c5..baab6fe579 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -30,13 +30,14 @@ import androidx.core.widget.doOnTextChanged import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavDirections import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy import com.infomaniak.core.legacy.utils.Utils import com.infomaniak.core.legacy.utils.hideKeyboard +import com.infomaniak.core.legacy.utils.safeNavigate import com.infomaniak.core.legacy.utils.setMargins -import com.infomaniak.core.legacy.utils.setPagination import com.infomaniak.core.legacy.utils.showKeyboard import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation.DirectionFlag @@ -48,15 +49,23 @@ import com.infomaniak.mail.MatomoMail.trackSearchEvent import com.infomaniak.mail.MatomoMail.trackThreadListEvent import com.infomaniak.mail.R import com.infomaniak.mail.data.models.Folder +import com.infomaniak.mail.data.models.SwipeAction import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.databinding.FragmentSearchBinding +import com.infomaniak.mail.ui.MainActivity +import com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog +import com.infomaniak.mail.ui.main.folder.MultiSelectionBinding +import com.infomaniak.mail.ui.main.folder.MultiSelectionHost +import com.infomaniak.mail.ui.main.folder.MultiSelectionListener import com.infomaniak.mail.ui.main.folder.ThreadListAdapterCallbacks +import com.infomaniak.mail.ui.main.folder.ThreadListMultiSelection import com.infomaniak.mail.ui.main.folder.TwoPaneFragment import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.thread.ThreadFragment +import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.Utils.Shortcuts import com.infomaniak.mail.utils.extensions.addStickyDateDecoration import com.infomaniak.mail.utils.extensions.applySideAndBottomSystemInsets @@ -69,19 +78,36 @@ import com.infomaniak.mail.utils.extensions.setOnClearTextClickListener import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import javax.inject.Inject @AndroidEntryPoint -class SearchFragment : TwoPaneFragment() { +class SearchFragment : TwoPaneFragment(), MultiSelectionHost { private var _binding: FragmentSearchBinding? = null private val binding get() = _binding!! // This property is only valid between onCreateView and onDestroyView + @Inject + override lateinit var folderRoleUtils: FolderRoleUtils + @Inject + override lateinit var descriptionDialog: DescriptionAlertDialog + + override val multiSelectionBinding: MultiSelectionBinding + get() = object : MultiSelectionBinding { + override val quickActionBar get() = binding.quickActionBar + override val multiselectToolbar get() = binding.multiselectToolbar + override val toolbarLayout get() = binding.multiselectToolbar.toolbar + override val toolbar get() = binding.toolbar + override val threadsList get() = binding.mailRecyclerView + override val newMessageFab get() = null + override val unreadCountChip get() = null + } + private val searchViewModel: SearchViewModel by activityViewModels() override val substituteClassName: String = javaClass.name private val showLoadingTimer: CountDownTimer by lazy { Utils.createRefreshTimer(onTimerFinish = ::showRefreshLayout) } - + private val threadListMultiSelection by lazy { ThreadListMultiSelection() } private val recentSearchAdapter by lazy { RecentSearchAdapter( searchQueries = localSettings.recentSearches.toMutableList(), @@ -119,6 +145,15 @@ class SearchFragment : TwoPaneFragment() { setupAdapter() setupListeners() + threadListMultiSelection.initMultiSelection( + mainViewModel = mainViewModel, + actionsViewModel = actionsViewModel, + host = this, + activity = (requireActivity() as MainActivity), + unlockSwipeActionsIfSet = ::unlockSwipeActionsIfSet, + localSettings = localSettings, + ) + setAllFoldersButtonListener() setAttachmentsUi() setMutuallyExclusiveChipGroupUi() @@ -218,6 +253,11 @@ class SearchFragment : TwoPaneFragment() { } } }, + multiSelection = object : MultiSelectionListener { + override var isEnabled by mainViewModel::isMultiSelectOn + override val selectedItems by mainViewModel::selectedThreads + override val publishSelectedItems = mainViewModel::publishSelectedItems + }, ) threadListAdapter.stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY @@ -414,6 +454,42 @@ class SearchFragment : TwoPaneFragment() { binding.swipeRefreshLayout.isRefreshing = true } + override fun safeNavigation(directions: NavDirections) { + safeNavigate(directions) + } + + override fun disableSwipeDirection(direction: DirectionFlag) { + binding.mailRecyclerView.disableSwipeDirection(direction) + } + + override fun unlockSwipeActionsIfSet() = with(binding.mailRecyclerView) { + val isMultiSelectClosed = mainViewModel.isMultiSelectOn.not() + + val isLeftSet = localSettings.swipeLeft != SwipeAction.NONE + val isLeftEnabled = isLeftSet && isMultiSelectClosed + if (isLeftEnabled) enableSwipeDirection(DirectionFlag.LEFT) else disableSwipeDirection(DirectionFlag.LEFT) + + val isRightSet = localSettings.swipeRight != SwipeAction.NONE + val isRightEnabled = isRightSet && isMultiSelectClosed + if (isRightEnabled) enableSwipeDirection(DirectionFlag.RIGHT) else disableSwipeDirection(DirectionFlag.RIGHT) + } + + override fun directionToThreadActionsBottomSheetDialog( + threadUid: String, + shouldLoadDistantResources: Boolean, + shouldCloseMultiSelection: Boolean + ): NavDirections { + return SearchFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( + threadUid, + shouldLoadDistantResources, + shouldCloseMultiSelection + ) + } + + override fun directionsToMultiSelectBottomSheetDialog(): NavDirections { + return SearchFragmentDirections.actionThreadListFragmentToMultiSelectBottomSheetDialog() + } + enum class VisibilityMode { RECENT_SEARCHES, LOADING, diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index 80070caa18..c6e537a0e9 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -49,6 +49,10 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/main_navigation.xml b/app/src/main/res/navigation/main_navigation.xml index c6b173b215..4c33b5368e 100644 --- a/app/src/main/res/navigation/main_navigation.xml +++ b/app/src/main/res/navigation/main_navigation.xml @@ -59,12 +59,12 @@ - + @@ -152,6 +152,13 @@ + + + Date: Mon, 26 Jan 2026 14:57:26 +0100 Subject: [PATCH 07/52] feat: Multiselect ui in search # Conflicts: # app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt # app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt --- .../main/folder/PerformSwipeActionManager.kt | 171 +++++++++++ .../mail/ui/main/folder/ThreadListFragment.kt | 1 + .../main/folder/ThreadListMultiSelection.kt | 62 +++- .../mail/ui/main/search/SearchFragment.kt | 287 ++++++++++++++---- .../mail/ui/main/search/SearchViewModel.kt | 7 + app/src/main/res/layout/fragment_search.xml | 4 +- .../view_multiselection_info_toolbar.xml | 5 +- .../main/res/navigation/main_navigation.xml | 4 +- 8 files changed, 481 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index ae05f84ee4..7b79ff4046 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -17,6 +17,8 @@ */ package com.infomaniak.mail.ui.main.folder +import androidx.fragment.app.Fragment +import androidx.navigation.NavDirections import androidx.navigation.fragment.findNavController import com.infomaniak.core.fragmentnavigation.safelyNavigate import com.infomaniak.core.matomo.Matomo.TrackerAction @@ -32,18 +34,186 @@ import com.infomaniak.mail.data.models.isSnoozed import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter +import com.infomaniak.mail.ui.MainViewModel +import com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.settings.appearance.swipe.SwipeActionsSettingsFragment import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType +import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel import com.infomaniak.mail.utils.extensions.animatedNavigation import com.infomaniak.mail.utils.extensions.archiveWithConfirmationPopup import com.infomaniak.mail.utils.extensions.deleteWithConfirmationPopup import com.infomaniak.mail.utils.extensions.getAnimatedNavOptions import com.infomaniak.mail.utils.extensions.moveWithConfirmationPopup +import io.realm.kotlin.types.RealmInstant import com.infomaniak.core.common.R as RCore object PerformSwipeActionManager { + interface SwipeActionHost { + val fragment: Fragment + val mainViewModel: MainViewModel + val actionsViewModel: ActionsViewModel + val localSettings: LocalSettings + val threadListAdapter: ThreadListAdapter + val descriptionDialog: DescriptionAlertDialog + + fun showSwipeActionIncompatible() + + fun directionsToMove(threadUid: String, sourceFolderId: String): NavDirections + fun directionsToQuickActions(threadUid: String): NavDirections + + fun navigateToSnoozeBottomSheet(snoozeScheduleType: SnoozeScheduleType?, snoozeEndDate: RealmInstant?) + } + + /** + * Generic API usable by SearchFragment (and others). + * + * The boolean return value is used to know if we should keep the Thread in + * the RecyclerView (true), or remove it when the swipe is done (false). + */ + fun performSwipeAction( + host: SwipeActionHost, + swipeAction: SwipeAction, + thread: Thread, + position: Int, + isPermanentDeleteFolder: Boolean, + ): Boolean { + val folderRole = thread.folder.role + if (!swipeAction.canDisplay(folderRole, host.mainViewModel.featureFlagsLive.value, host.localSettings)) { + host.showSwipeActionIncompatible() + return true + } + + trackEvent(MatomoCategory.SwipeActions, swipeAction.matomoName, TrackerAction.DRAG) + + val shouldKeepItemBecauseOfAction = performSwipeActionInternal( + host = host, + swipeAction = swipeAction, + folderRole = folderRole, + thread = thread, + position = position, + isPermanentDeleteFolder = isPermanentDeleteFolder, + ) + + val shouldKeepItemBecauseOfNoConnection = !host.mainViewModel.hasNetwork + return shouldKeepItemBecauseOfAction || shouldKeepItemBecauseOfNoConnection + } + + private fun performSwipeActionInternal( + host: SwipeActionHost, + swipeAction: SwipeAction, + folderRole: FolderRole?, + thread: Thread, + position: Int, + isPermanentDeleteFolder: Boolean, + ) = when (swipeAction) { + SwipeAction.TUTORIAL -> { + host.localSettings.setDefaultSwipeActions() + host.fragment.findNavController() + .navigate(R.id.swipeActionsSettingsFragment, args = null, getAnimatedNavOptions()) + true + } + + SwipeAction.ARCHIVE -> { + host.descriptionDialog.archiveWithConfirmationPopup( + folderRole = folderRole, + count = 1, + displayLoader = false, + onCancel = { + if (host.threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { + host.threadListAdapter.notifyItemChanged(position) + } + }, + ) { + host.actionsViewModel.archiveThreads( + listOf(thread), + host.mainViewModel.currentFolder.value, + host.mainViewModel.currentMailbox.value!! + ) + } + } + + SwipeAction.DELETE -> { + host.descriptionDialog.deleteWithConfirmationPopup( + folderRole = folderRole, + count = 1, + displayLoader = false, + onCancel = { + if (host.threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { + host.threadListAdapter.notifyItemChanged(position) + } + }, + callback = { + if (isPermanentDeleteFolder) host.threadListAdapter.removeItem(position) + host.actionsViewModel.deleteThreads( + listOf(thread), + host.mainViewModel.currentFolder.value, + host.mainViewModel.currentMailbox.value!! + ) + }, + ) + } + + SwipeAction.FAVORITE -> { + host.actionsViewModel.toggleThreadsFavoriteStatus( + threadsUids = listOf(thread.uid), + shouldFavorite = !thread.isFavorite, + mailbox = host.mainViewModel.currentMailbox.value!! + ) + true + } + + SwipeAction.MOVE -> { + val navController = host.fragment.findNavController() + host.descriptionDialog.moveWithConfirmationPopup(folderRole, count = 1) { + navController.animatedNavigation( + directions = host.directionsToMove( + threadUid = thread.uid, + sourceFolderId = host.mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID, + ) + ) + } + true + } + + SwipeAction.QUICKACTIONS_MENU -> { + host.fragment.safelyNavigate(host.directionsToQuickActions(thread.uid)) + true + } + + SwipeAction.READ_UNREAD -> { + host.actionsViewModel.toggleThreadsSeenStatus( + threadsUids = listOf(thread.uid), + shouldRead = !thread.isSeen, + currentFolderId = host.mainViewModel.currentFolderId, + mailbox = host.mainViewModel.currentMailbox.value!! + ) + host.mainViewModel.currentFilter.value != ThreadFilter.UNSEEN + } + + SwipeAction.SPAM -> { + host.actionsViewModel.toggleThreadsSpamStatus( + threads = setOf(thread), + currentFolderId = host.mainViewModel.currentFolderId, + mailbox = host.mainViewModel.currentMailbox.value!! + ) + false + } + + SwipeAction.SNOOZE -> { + val snoozeScheduleType = if (thread.isSnoozed()) { + SnoozeScheduleType.Modify(thread.uid) + } else { + SnoozeScheduleType.Snooze(thread.uid) + } + host.navigateToSnoozeBottomSheet(snoozeScheduleType, thread.snoozeEndDate) + true + } + + SwipeAction.NONE -> error("Cannot swipe on an action which is not set") + } + /** * The boolean return value is used to know if we should keep the Thread in * the RecyclerView (true), or remove it when the swipe is done (false). @@ -224,6 +394,7 @@ object PerformSwipeActionManager { ) } + private fun LocalSettings.setDefaultSwipeActions() { if (swipeRight == SwipeAction.TUTORIAL) swipeRight = SwipeActionsSettingsFragment.DEFAULT_SWIPE_ACTION_RIGHT if (swipeLeft == SwipeAction.TUTORIAL) swipeLeft = SwipeActionsSettingsFragment.DEFAULT_SWIPE_ACTION_LEFT 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 778335a07b..b36cfa66ac 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 @@ -206,6 +206,7 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio actionsViewModel = actionsViewModel, activity = (requireActivity() as MainActivity), host = this, + searchViewModel = null, unlockSwipeActionsIfSet = ::unlockSwipeActionsIfSet, localSettings = localSettings, ) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index e8438f9329..8e2d3c68cb 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -17,6 +17,7 @@ */ package com.infomaniak.mail.ui.main.folder +// import com.infomaniak.mail.ui.main.folder.``.computeReadFavoriteStatus import android.transition.AutoTransition import android.transition.TransitionManager import androidx.core.view.isGone @@ -34,6 +35,7 @@ import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.ui.MainActivity import com.infomaniak.mail.ui.MainViewModel +import com.infomaniak.mail.ui.main.search.SearchViewModel import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel import com.infomaniak.mail.utils.Utils.runCatchingRealm import com.infomaniak.mail.utils.extensions.archiveWithConfirmationPopup @@ -75,6 +77,7 @@ class ThreadListMultiSelection { private lateinit var threadListFragment: ThreadListFragment lateinit var mainActivity: MainActivity + lateinit var searchViewModel: SearchViewModel private lateinit var host: MultiSelectionHost lateinit var unlockSwipeActionsIfSet: () -> Unit @@ -88,6 +91,7 @@ class ThreadListMultiSelection { actionsViewModel: ActionsViewModel, activity: MainActivity, host: MultiSelectionHost, + searchViewModel: SearchViewModel?, unlockSwipeActionsIfSet: () -> Unit, localSettings: LocalSettings, ) { @@ -98,11 +102,67 @@ class ThreadListMultiSelection { this.unlockSwipeActionsIfSet = unlockSwipeActionsIfSet this.localSettings = localSettings - setupMultiSelectionActions() + if (searchViewModel != null) { + this.searchViewModel = searchViewModel + setupMessageMultiSelectionActions() + } else setupMultiSelectionActions() observerMultiSelection() } + private fun setupMessageMultiSelectionActions() = with(searchViewModel) { + host.multiSelectionBinding.quickActionBar.setOnItemClickListener { menuId -> + val selectedMessagesUids = selectedMessages.map { it.uid } + val selectedMessagesCount = selectedMessagesUids.count() + + when (menuId) { + R.id.quickActionUnread -> { + trackMultiSelectActionEvent(MatomoName.MarkAsSeen, selectedMessagesCount) + actionsViewModel.toggleMessagesSeenStatus( + selectedMessages.toList(), + false, + mainViewModel.currentFolderId, + mainViewModel.currentMailbox.value!! + ) + mainViewModel.isMultiSelectOn = false + } + // R.id.quickActionArchive -> host.lifecycleScope.launch { + // trackMultiSelectActionEvent(MatomoName.Archive, selectedMessagesCount) + // mainViewModel.archiveThreads(selectedMessagesUids) + // isMultiSelectOn = false + // } + // R.id.quickActionFavorite -> { + // trackMultiSelectActionEvent(MatomoName.Favorite, selectedThreadsCount) + // toggleThreadsFavoriteStatus(selectedThreadsUids, shouldMultiselectFavorite) + // isMultiSelectOn = false + // } + // R.id.quickActionDelete -> host.lifecycleScope.launch { + // host.descriptionDialog.deleteWithConfirmationPopup( + // folderRole = host.folderRoleUtils.getActionFolderRole(selectedThreads), + // count = selectedThreadsCount, + // ) { + // trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) + // deleteThreads(selectedThreadsUids) + // isMultiSelectOn = false + // } + // } + // R.id.quickActionMenu -> { + // trackMultiSelectActionEvent(MatomoName.OpenBottomSheet, selectedThreadsCount) + // val direction = if (selectedThreadsCount == 1) { + // host.directionToThreadActionsBottomSheetDialog( + // threadUid = selectedThreadsUids.single(), + // shouldLoadDistantResources = false, + // shouldCloseMultiSelection = true, + // ) + // } else { + // host.directionsToMultiSelectBottomSheetDialog() + // } + // host.safeNavigation(direction) + // } + } + } + } + private fun setupMultiSelectionActions() = with(mainViewModel) { host.multiSelectionBinding.quickActionBar.setOnItemClickListener { menuId -> val selectedThreadsUids = selectedThreads.map { it.uid } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index baab6fe579..8981fcead2 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -19,6 +19,8 @@ package com.infomaniak.mail.ui.main.search import android.os.Bundle import android.os.CountDownTimer +import android.transition.AutoTransition +import android.transition.TransitionManager import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -27,8 +29,10 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePaddingRelative import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.NavDirections import androidx.navigation.fragment.findNavController @@ -37,38 +41,47 @@ import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy import com.infomaniak.core.legacy.utils.Utils import com.infomaniak.core.legacy.utils.hideKeyboard import com.infomaniak.core.legacy.utils.safeNavigate -import com.infomaniak.core.legacy.utils.setMargins import com.infomaniak.core.legacy.utils.showKeyboard import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation.DirectionFlag +import com.infomaniak.dragdropswiperecyclerview.listener.OnItemSwipeListener +import com.infomaniak.dragdropswiperecyclerview.listener.OnItemSwipeListener.SwipeDirection import com.infomaniak.dragdropswiperecyclerview.listener.OnListScrollListener import com.infomaniak.dragdropswiperecyclerview.listener.OnListScrollListener.ScrollDirection import com.infomaniak.dragdropswiperecyclerview.listener.OnListScrollListener.ScrollState import com.infomaniak.mail.MatomoMail.MatomoName +import com.infomaniak.mail.MatomoMail.trackMultiSelectionEvent import com.infomaniak.mail.MatomoMail.trackSearchEvent import com.infomaniak.mail.MatomoMail.trackThreadListEvent import com.infomaniak.mail.R -import com.infomaniak.mail.data.models.Folder +import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.SwipeAction import com.infomaniak.mail.data.models.correspondent.MergedContact -import com.infomaniak.mail.data.models.mailbox.Mailbox +import com.infomaniak.mail.data.models.mailbox.Mailbox.FeatureFlagSet import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.databinding.FragmentSearchBinding import com.infomaniak.mail.ui.MainActivity import com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog +import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.folder.MultiSelectionBinding import com.infomaniak.mail.ui.main.folder.MultiSelectionHost import com.infomaniak.mail.ui.main.folder.MultiSelectionListener +import com.infomaniak.mail.ui.main.folder.PerformSwipeActionManager import com.infomaniak.mail.ui.main.folder.ThreadListAdapterCallbacks +import com.infomaniak.mail.ui.main.folder.ThreadListItem import com.infomaniak.mail.ui.main.folder.ThreadListMultiSelection +import com.infomaniak.mail.ui.main.folder.ThreadListViewModel import com.infomaniak.mail.ui.main.folder.TwoPaneFragment import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.thread.ThreadFragment +import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.Utils.Shortcuts +import com.infomaniak.mail.utils.Utils.isPermanentDeleteFolder import com.infomaniak.mail.utils.extensions.addStickyDateDecoration import com.infomaniak.mail.utils.extensions.applySideAndBottomSystemInsets +import com.infomaniak.mail.utils.extensions.applyStatusBarInsets import com.infomaniak.mail.utils.extensions.applyWindowInsetsListener import com.infomaniak.mail.utils.extensions.getLocalizedNameOrAllFolders import com.infomaniak.mail.utils.extensions.handleEditorSearchAction @@ -76,9 +89,11 @@ import com.infomaniak.mail.utils.extensions.safeArea import com.infomaniak.mail.utils.extensions.safelyAnimatedNavigation import com.infomaniak.mail.utils.extensions.setOnClearTextClickListener import dagger.hilt.android.AndroidEntryPoint +import io.realm.kotlin.types.RealmInstant import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import javax.inject.Inject +import com.infomaniak.core.legacy.R as RCore @AndroidEntryPoint class SearchFragment : TwoPaneFragment(), MultiSelectionHost { @@ -91,6 +106,45 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { @Inject override lateinit var descriptionDialog: DescriptionAlertDialog + override fun safeNavigation(directions: NavDirections) { + safeNavigate(directions) + } + + override fun disableSwipeDirection(direction: DirectionFlag) { + binding.mailRecyclerView.disableSwipeDirection(direction) + } + + override fun unlockSwipeActionsIfSet() = with(binding.mailRecyclerView) { + val isMultiSelectClosed = mainViewModel.isMultiSelectOn.not() + + val isLeftSet = localSettings.swipeLeft != SwipeAction.NONE + val isLeftEnabled = isLeftSet && isMultiSelectClosed + if (isLeftEnabled) enableSwipeDirection(DirectionFlag.LEFT) else disableSwipeDirection(DirectionFlag.LEFT) + + val isRightSet = localSettings.swipeRight != SwipeAction.NONE + val isRightEnabled = isRightSet && isMultiSelectClosed + if (isRightEnabled) enableSwipeDirection(DirectionFlag.RIGHT) else disableSwipeDirection(DirectionFlag.RIGHT) + } + + override fun directionToThreadActionsBottomSheetDialog( + threadUid: String, + shouldLoadDistantResources: Boolean, + shouldCloseMultiSelection: Boolean + ): NavDirections { + return SearchFragmentDirections.actionSearchFragmentToThreadActionsBottomSheetDialog( + threadUid, + shouldLoadDistantResources, + shouldCloseMultiSelection + ) + } + + override fun directionsToMultiSelectBottomSheetDialog(): NavDirections { + return SearchFragmentDirections.actionSearchFragmentToMultiSelectBottomSheetDialog() + } + + @Inject + lateinit var snackbarManager: SnackbarManager + override val multiSelectionBinding: MultiSelectionBinding get() = object : MultiSelectionBinding { override val quickActionBar get() = binding.quickActionBar @@ -103,9 +157,9 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { } private val searchViewModel: SearchViewModel by activityViewModels() + private val threadListViewModel: ThreadListViewModel by viewModels() override val substituteClassName: String = javaClass.name - private val showLoadingTimer: CountDownTimer by lazy { Utils.createRefreshTimer(onTimerFinish = ::showRefreshLayout) } private val threadListMultiSelection by lazy { ThreadListMultiSelection() } private val recentSearchAdapter by lazy { @@ -150,6 +204,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { actionsViewModel = actionsViewModel, host = this, activity = (requireActivity() as MainActivity), + searchViewModel = searchViewModel, unlockSwipeActionsIfSet = ::unlockSwipeActionsIfSet, localSettings = localSettings, ) @@ -164,25 +219,30 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { observeVisibilityModeUpdates() observeSearchResults() observeHistory() + observeMultiSelect() } private fun handleEdgeToEdge(): Unit = with(binding) { applyWindowInsetsListener(shouldConsume = false) { _, insets -> - toolbar.apply { - applySideAndBottomSystemInsets(insets, withBottom = false) - // Apply a margin instead of a padding so the icon and the search bar are aligned - setMargins(top = insets.safeArea().top) - } + appBar.applyStatusBarInsets(insets) chipsList.applySideAndBottomSystemInsets(insets, withBottom = false) swipeRefreshLayout.applySideAndBottomSystemInsets(insets, withBottom = false) + val recyclerViewPaddingBottom = resources.getDimensionPixelSize(RCore.dimen.recyclerViewPaddingBottom) + mailRecyclerView.updatePaddingRelative(bottom = recyclerViewPaddingBottom + insets.safeArea().bottom) + with(insets.safeArea()) { recentSearchesRecyclerView.updatePaddingRelative(bottom = bottom) - mailRecyclerView.updatePaddingRelative(bottom = bottom) } } } + override fun onResume() { + super.onResume() + updateSwipeActionsAccordingToSettings() + } + + override fun onStop() { searchViewModel.cancelSearch() super.onStop() @@ -213,13 +273,46 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { super.handleOnBackPressed() } + private fun updateSwipeActionsAccordingToSettings() { + unlockSwipeActionsIfSet() + + // Manually update disabled ui in case LocalSettings have changed when coming back from settings + updateDisabledSwipeActionsUi( + featureFlags = mainViewModel.featureFlagsLive.value, + folderRole = mainViewModel.currentFolderLive.value?.role, + ) + } + + private fun updateDisabledSwipeActionsUi(featureFlags: FeatureFlagSet?, folderRole: FolderRole?) { + val isLeftEnabled = localSettings.swipeLeft.canDisplay(folderRole, featureFlags, localSettings) + val isRightEnabled = localSettings.swipeRight.canDisplay(folderRole, featureFlags, localSettings) + + setSwipeActionEnabledUi(DirectionFlag.LEFT, isLeftEnabled) + setSwipeActionEnabledUi(DirectionFlag.RIGHT, isRightEnabled) + } + + private fun setSwipeActionEnabledUi(swipeDirection: DirectionFlag, isEnabled: Boolean) = with(binding.mailRecyclerView) { + fun SwipeAction.getIconRes(): Int? = if (isEnabled) iconRes else R.drawable.ic_close_small + fun SwipeAction.getBackgroundColor(): Int { + return if (isEnabled) getBackgroundColor(context) else SwipeAction.NONE.getBackgroundColor(context) + } + + if (swipeDirection == DirectionFlag.LEFT) { + behindSwipedItemIconDrawableId = localSettings.swipeLeft.getIconRes() + behindSwipedItemBackgroundColor = localSettings.swipeLeft.getBackgroundColor() + } else { + behindSwipedItemIconSecondaryDrawableId = localSettings.swipeRight.getIconRes() + behindSwipedItemBackgroundSecondaryColor = localSettings.swipeRight.getBackgroundColor() + } + } + private fun setupAdapter() { threadListAdapter( folderRole = null, isFolderNameVisible = true, callbacks = object : ThreadListAdapterCallbacks { - override var onSwipeFinished: (() -> Unit)? = null + override var onSwipeFinished: (() -> Unit)? = { threadListViewModel.isRecoveringFinished.value = true } override var onThreadClicked: (Thread) -> Unit = { thread -> with(searchViewModel) { @@ -243,8 +336,6 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { override var deleteThreadInRealm: (String) -> Unit = { threadUid -> mainViewModel.deleteThreadInRealm(threadUid) } - override val getFeatureFlags: () -> Mailbox.FeatureFlagSet? = { mainViewModel.featureFlagsLive.value } - override var onContactClicked: ((MergedContact) -> Unit)? = { contact -> val emailWithQuotes = "\"${contact.email}\"" binding.searchBar.searchTextInput.apply { @@ -252,6 +343,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { setSelection(emailWithQuotes.length) } } + override val getFeatureFlags: () -> FeatureFlagSet? = { mainViewModel.featureFlagsLive.value } }, multiSelection = object : MultiSelectionListener { override var isEnabled by mainViewModel::isMultiSelectOn @@ -268,9 +360,105 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { searchViewModel.clearSearchState() findNavController().popBackStack() } + multiselectToolbar.cancel.setOnClickListener { + trackMultiSelectionEvent(MatomoName.Cancel) + mainViewModel.isMultiSelectOn = false + } + multiselectToolbar.selectAll.setOnClickListener { + mainViewModel.selectOrUnselectAll() + threadListAdapter.updateSelection() + } + mailRecyclerView.swipeListener = object : OnItemSwipeListener { + override fun onItemSwiped(position: Int, direction: SwipeDirection, item: ThreadListItem.Content): Boolean { + + val swipeAction = when (direction) { + SwipeDirection.LEFT_TO_RIGHT -> localSettings.swipeRight + SwipeDirection.RIGHT_TO_LEFT -> localSettings.swipeLeft + else -> error("Only SwipeDirection.LEFT_TO_RIGHT and SwipeDirection.RIGHT_TO_LEFT can be triggered") + } + + val isPermanentDeleteFolder = isPermanentDeleteFolder(item.thread.folder.role) + + val shouldKeepItem = performSwipeActionOnThread(swipeAction, item.thread, position, isPermanentDeleteFolder) + + threadListAdapter.apply { + blockOtherSwipes() + + if (swipeAction == SwipeAction.DELETE && isPermanentDeleteFolder) { + Unit // The swiped Thread stay swiped all the way + } else { + notifyItemChanged(position) // Animate the swiped Thread back to its original position + } + } + + threadListViewModel.isRecoveringFinished.value = false + + // The return value of this callback is used to determine if the + // swiped item should be kept or deleted from the adapter's list. + return shouldKeepItem + } + } + swipeRefreshLayout.setOnRefreshListener { searchViewModel.refreshSearch() } } + /** + * The boolean return value is used to know if we should keep the Thread in + * the RecyclerView (true), or remove it when the swipe is done (false). + */ + + private fun performSwipeActionOnThread( + swipeAction: SwipeAction, + thread: Thread, + position: Int, + isPermanentDeleteFolder: Boolean, + ): Boolean = with(PerformSwipeActionManager) { + + val host = object : PerformSwipeActionManager.SwipeActionHost { + override val fragment: Fragment = this@SearchFragment + override val mainViewModel = this@SearchFragment.mainViewModel + override val actionsViewModel = this@SearchFragment.actionsViewModel + override val localSettings = this@SearchFragment.localSettings + override val threadListAdapter = this@SearchFragment.threadListAdapter + override val descriptionDialog = this@SearchFragment.descriptionDialog + + override fun showSwipeActionIncompatible() { + snackbarManager.setValue(getString(R.string.snackbarSwipeActionIncompatible)) + } + + override fun directionsToMove(threadUid: String, sourceFolderId: String): NavDirections { + return SearchFragmentDirections.actionSearchFragmentToFolderPickerFragment( + threadsUids = arrayOf(threadUid), + action = FolderPickerAction.MOVE, + sourceFolderId = sourceFolderId, + ) + } + + override fun directionsToQuickActions(threadUid: String): NavDirections { + return SearchFragmentDirections.actionSearchFragmentToThreadActionsBottomSheetDialog( + threadUid = threadUid, + shouldLoadDistantResources = false, + shouldCloseMultiSelection = false, + ) + } + + override fun navigateToSnoozeBottomSheet( + snoozeScheduleType: SnoozeScheduleType?, + snoozeEndDate: RealmInstant?, + ) { + this@SearchFragment.navigateToSnoozeBottomSheet(snoozeScheduleType, snoozeEndDate) + } + } + + return performSwipeAction( + host = host, + swipeAction = swipeAction, + thread = thread, + position = position, + isPermanentDeleteFolder = isPermanentDeleteFolder, + ) + } + private fun setAllFoldersButtonListener() { binding.allFoldersButton.setOnClickListener { safelyAnimatedNavigation( @@ -286,10 +474,17 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { private fun selectCurrentFolder() { val sourceFolder = mainViewModel.currentFolder.value - if (!searchViewModel.isAllFoldersSelected && searchViewModel.filterFolder == null && sourceFolder?.role != Folder.FolderRole.INBOX) { + if (!searchViewModel.isAllFoldersSelected && searchViewModel.filterFolder == null && sourceFolder?.role != FolderRole.INBOX) { searchViewModel.selectFolder(sourceFolder) } updateAllFoldersButtonUi() + + if (searchViewModel.filterFolder == null) { + val currentFolder = mainViewModel.currentFolder.value + if (currentFolder?.role != FolderRole.INBOX) { + searchViewModel.selectFolder(currentFolder) + } + } trackSearchEvent(ThreadFilter.FOLDER.matomoName, true) } @@ -346,11 +541,6 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { disableDragDirection(DirectionFlag.UP) disableDragDirection(DirectionFlag.DOWN) - disableDragDirection(DirectionFlag.LEFT) - disableDragDirection(DirectionFlag.RIGHT) - - disableSwipeDirection(DirectionFlag.LEFT) - disableSwipeDirection(DirectionFlag.RIGHT) addStickyDateDecoration(threadListAdapter, localSettings.threadDensity) setPagination() @@ -391,6 +581,19 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { searchViewModel.visibilityMode.observe(viewLifecycleOwner, ::updateUi) } + private fun removeMultiSelectItems(deletedIndices: IntArray) = with(mainViewModel) { + if (isMultiSelectOn) { + val previousThreads = threadListAdapter.dataSet.filterIsInstance() + var shouldPublish = false + deletedIndices.forEach { + val thread = previousThreads.getOrElse(it) { return@forEach }.thread + val isRemoved = mainViewModel.selectedThreads.remove(thread) + if (isRemoved) shouldPublish = true + } + if (shouldPublish) publishSelectedItems() + } + } + private fun updateUi(mode: VisibilityMode) = with(binding) { fun displayRecentSearches() { @@ -445,6 +648,15 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { } } + private fun observeMultiSelect() { + mainViewModel.isMultiSelectOnLiveData.observe(viewLifecycleOwner) { isMultiSelectOn -> + val autoTransition = AutoTransition() + autoTransition.duration = TOOLBAR_FADE_DURATION + TransitionManager.beginDelayedTransition(binding.horizontalScrollViewFilters, autoTransition) + binding.horizontalScrollViewFilters.isGone = isMultiSelectOn + } + } + private fun updateHistoryEmptyStateVisibility(isThereHistory: Boolean) = with(binding) { recentSearches.isVisible = isThereHistory noHistory.isGone = isThereHistory @@ -454,42 +666,6 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { binding.swipeRefreshLayout.isRefreshing = true } - override fun safeNavigation(directions: NavDirections) { - safeNavigate(directions) - } - - override fun disableSwipeDirection(direction: DirectionFlag) { - binding.mailRecyclerView.disableSwipeDirection(direction) - } - - override fun unlockSwipeActionsIfSet() = with(binding.mailRecyclerView) { - val isMultiSelectClosed = mainViewModel.isMultiSelectOn.not() - - val isLeftSet = localSettings.swipeLeft != SwipeAction.NONE - val isLeftEnabled = isLeftSet && isMultiSelectClosed - if (isLeftEnabled) enableSwipeDirection(DirectionFlag.LEFT) else disableSwipeDirection(DirectionFlag.LEFT) - - val isRightSet = localSettings.swipeRight != SwipeAction.NONE - val isRightEnabled = isRightSet && isMultiSelectClosed - if (isRightEnabled) enableSwipeDirection(DirectionFlag.RIGHT) else disableSwipeDirection(DirectionFlag.RIGHT) - } - - override fun directionToThreadActionsBottomSheetDialog( - threadUid: String, - shouldLoadDistantResources: Boolean, - shouldCloseMultiSelection: Boolean - ): NavDirections { - return SearchFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( - threadUid, - shouldLoadDistantResources, - shouldCloseMultiSelection - ) - } - - override fun directionsToMultiSelectBottomSheetDialog(): NavDirections { - return SearchFragmentDirections.actionThreadListFragmentToMultiSelectBottomSheetDialog() - } - enum class VisibilityMode { RECENT_SEARCHES, LOADING, @@ -499,5 +675,6 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { companion object { private const val PAGINATION_TRIGGER_OFFSET = 15 + private const val TOOLBAR_FADE_DURATION = 150L } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index d5b09a48ee..a508a5016c 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -39,6 +39,7 @@ import com.infomaniak.mail.data.cache.userInfo.MergedContactController import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.Companion.DUMMY_FOLDER_ID import com.infomaniak.mail.data.models.correspondent.MergedContact +import com.infomaniak.mail.data.models.message.Message import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.di.IoDispatcher @@ -88,6 +89,12 @@ class SearchViewModel @Inject constructor( private val dummyFolderId inline get() = savedStateHandle.get(SearchFragmentArgs::dummyFolderId.name) ?: DUMMY_FOLDER_ID + val selectedMessagesLiveData = MutableLiveData(mutableSetOf()) + inline val selectedMessages + get() = selectedMessagesLiveData.value!! + + val isEveryMessageSelected + get() = runCatchingRealm { selectedMessages.count() == searchResults.value?.list?.count() }.getOrDefault(false) var filterFolder: Folder? = null private set var currentSearchQuery: String = "" diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index c6e537a0e9..495489fed6 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -59,7 +59,7 @@ Without the ID, it resets to 0 every time. --> + app:layout_collapseMode="pin" + app:navigationIcon="@null" + tools:visibility="visible"> From a59940c64be9506cc5c37efbeec1c5596f9ff1ce Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Wed, 13 May 2026 16:32:30 +0200 Subject: [PATCH 08/52] refactor: Create MultiselectionViewModel --- .../com/infomaniak/mail/ui/MainActivity.kt | 6 +- .../com/infomaniak/mail/ui/MainViewModel.kt | 31 ----- .../main/folder/PerformSwipeActionManager.kt | 4 +- .../mail/ui/main/folder/ThreadListFragment.kt | 34 +++-- .../main/folder/ThreadListMultiSelection.kt | 125 +++--------------- .../mail/ui/main/search/SearchFragment.kt | 33 +++-- .../mail/ui/main/search/SearchViewModel.kt | 2 - .../DetailedContactBottomSheetDialog.kt | 4 +- .../actions/ActionsBottomSheetDialog.kt | 8 +- .../AttachmentActionsBottomSheetDialog.kt | 4 +- .../actions/MailActionsBottomSheetDialog.kt | 2 +- .../MessageActionsBottomSheetDialog.kt | 2 + .../actions/MultiSelectBottomSheetDialog.kt | 6 +- .../thread/actions/ReplyBottomSheetDialog.kt | 5 +- .../actions/ThreadActionsBottomSheetDialog.kt | 6 +- .../actions/UserToBlockBottomSheetDialog.kt | 4 +- .../multiselection/MultiselectionBinding.kt | 29 ++++ .../multiselection/MultiselectionHost.kt | 40 ++++++ .../multiselection/MultiselectionViewModel.kt | 65 +++++++++ ...nencryptableRecipientsBottomSheetDialog.kt | 2 +- .../EncryptionActionsBottomSheet.kt | 2 +- 21 files changed, 232 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionBinding.kt create mode 100644 app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt create mode 100644 app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt b/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt index e12baef41d..c4a20cb45d 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt @@ -76,6 +76,7 @@ import com.infomaniak.mail.ui.main.menuDrawer.MenuDrawerFragment import com.infomaniak.mail.ui.main.onboarding.PermissionsOnboardingPagerFragment import com.infomaniak.mail.ui.main.search.SearchFragmentArgs import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.ui.newMessage.NewMessageActivity import com.infomaniak.mail.ui.sync.SyncAutoConfigActivity import com.infomaniak.mail.ui.sync.discovery.SyncDiscoveryManager @@ -115,6 +116,7 @@ class MainActivity : BaseActivity() { private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } private val mainViewModel: MainViewModel by viewModels() private val actionsViewModel: ActionsViewModel by viewModels() + private val multiselectionViewModel: MultiselectionViewModel by viewModels() private val navigationArgs: MainActivityArgs? by lazy { intent?.extras?.let(MainActivityArgs::fromBundle) } @@ -467,7 +469,7 @@ class MainActivity : BaseActivity() { } fun closeMultiSelect() { - mainViewModel.isMultiSelectOn = false + multiselectionViewModel.isMultiSelectOn = false } fun popBack() { @@ -481,7 +483,7 @@ class MainActivity : BaseActivity() { onBackPressedDispatcher.addCallback(this@MainActivity) { when { drawerLayout.isOpen -> closeDrawer() - mainViewModel.isMultiSelectOn -> closeMultiSelect() + multiselectionViewModel.isMultiSelectOn -> closeMultiSelect() else -> popBack() } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt index d6acc1fa0c..2bddd1fbfb 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -37,8 +37,6 @@ import com.infomaniak.core.network.networking.ManualAuthorizationRequired import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError import com.infomaniak.core.sentry.SentryLog import com.infomaniak.core.ui.showToast -import com.infomaniak.mail.MatomoMail.MatomoName -import com.infomaniak.mail.MatomoMail.trackMultiSelectionEvent import com.infomaniak.mail.R import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.api.ApiRepository @@ -81,7 +79,6 @@ import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.SharedUtils.Companion.updateSignatures import com.infomaniak.mail.utils.Utils import com.infomaniak.mail.utils.Utils.EML_CONTENT_TYPE -import com.infomaniak.mail.utils.Utils.runCatchingRealm import com.infomaniak.mail.utils.coroutineContext import com.infomaniak.mail.utils.extensions.MergedContactDictionary import com.infomaniak.mail.utils.extensions.allFailed @@ -173,19 +170,7 @@ class MainViewModel @Inject constructor( val mailboxesLive = mailboxController.getMailboxesAsync(AccountUtils.currentUserId).asLiveData(ioCoroutineContext) //region Multi selection - val isMultiSelectOnLiveData = MutableLiveData(false) - inline var isMultiSelectOn - get() = isMultiSelectOnLiveData.value!! - set(value) { - isMultiSelectOnLiveData.value = value - } - - val selectedThreadsLiveData = MutableLiveData(mutableSetOf()) - inline val selectedThreads - get() = selectedThreadsLiveData.value!! - val isEverythingSelected - get() = runCatchingRealm { selectedThreads.count() == currentThreadsLive.value?.list?.count() }.getOrDefault(false) //endregion //region Current Mailbox @@ -819,22 +804,6 @@ class MainViewModel @Inject constructor( suspend fun getMessage(messageUid: String): Message? = messageController.getMessage(messageUid) - fun selectOrUnselectAll() { - if (isEverythingSelected) { - trackMultiSelectionEvent(MatomoName.None) - selectedThreads.clear() - } else { - trackMultiSelectionEvent(MatomoName.All) - currentThreadsLive.value?.list?.forEach { thread -> selectedThreads.add(thread) } - } - - publishSelectedItems() - } - - fun publishSelectedItems() { - selectedThreadsLiveData.value = selectedThreads - } - fun refreshDraftFolderWhenDraftArrives(scheduledMessageEtop: Long) = viewModelScope.launch(ioCoroutineContext) { val folder = folderController.getFolder(FolderRole.DRAFT) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index 7b79ff4046..a32e85d55c 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -135,8 +135,10 @@ object PerformSwipeActionManager { } SwipeAction.DELETE -> { + val folderRoles = + thread.messages.mapNotNull { message -> if (message.isSnoozed()) FolderRole.SNOOZED else message.folder.role } host.descriptionDialog.deleteWithConfirmationPopup( - folderRole = folderRole, + folderRoles = folderRoles, count = 1, displayLoader = false, onCancel = { 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 b36cfa66ac..7f1c093ec3 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 @@ -46,9 +46,9 @@ import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy import com.infomaniak.core.common.extensions.goToAppStore - import com.infomaniak.core.common.observe import com.infomaniak.core.common.utils.isToday +import com.infomaniak.core.fragmentnavigation.safelyNavigate import com.infomaniak.core.inappupdate.updatemanagers.InAppUpdateManager import com.infomaniak.core.ksuite.data.KSuite import com.infomaniak.core.legacy.utils.SnackbarUtils.showSnackbar @@ -94,6 +94,9 @@ import com.infomaniak.mail.ui.main.emojiPicker.PickerEmojiObserver import com.infomaniak.mail.ui.main.folder.ThreadListViewModel.ContentDisplayMode import com.infomaniak.mail.ui.main.thread.ThreadFragment import com.infomaniak.mail.ui.main.thread.actions.EmojiReactionsViewModel +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionBinding +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionHost +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.ui.main.user.SwitchUserViewModel import com.infomaniak.mail.ui.newMessage.NewMessageActivityArgs import com.infomaniak.mail.utils.AccountUtils @@ -134,6 +137,7 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio private val navigationArgs: ThreadListFragmentArgs by navArgs() private val threadListViewModel: ThreadListViewModel by viewModels() + private val multiselectionViewModel: MultiselectionViewModel by viewModels() private val emojiReactionsViewModel: EmojiReactionsViewModel by viewModels() override val substituteClassName: String = javaClass.name @@ -152,16 +156,17 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio @Inject override lateinit var descriptionDialog: DescriptionAlertDialog + @Inject + override lateinit var folderRoleUtils: FolderRoleUtils + override fun safeNavigation(directions: NavDirections) { - safeNavigate(directions) + safelyNavigate(directions) } override fun disableSwipeDirection(direction: DirectionFlag) { binding.threadsList.disableSwipeDirection(direction) } - override lateinit var folderRoleUtils: FolderRoleUtils - @Inject lateinit var downloadThreadsStatusManager: DownloadThreadsStatusManager @@ -204,9 +209,10 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio threadListMultiSelection.initMultiSelection( mainViewModel = mainViewModel, actionsViewModel = actionsViewModel, + multiselectionViewModel = multiselectionViewModel, activity = (requireActivity() as MainActivity), host = this, - searchViewModel = null, + folderRoleUtils = folderRoleUtils, unlockSwipeActionsIfSet = ::unlockSwipeActionsIfSet, localSettings = localSettings, ) @@ -355,7 +361,7 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio } override fun unlockSwipeActionsIfSet() = with(binding.threadsList) { - val isMultiSelectClosed = mainViewModel.isMultiSelectOn.not() + val isMultiSelectClosed = multiselectionViewModel.isMultiSelectOn.not() val isLeftSet = localSettings.swipeLeft != SwipeAction.NONE val isLeftEnabled = isLeftSet && isMultiSelectClosed @@ -439,9 +445,9 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio override var onContactClicked: ((MergedContact) -> Unit)? = null }, multiSelection = object : MultiSelectionListener { - override var isEnabled by mainViewModel::isMultiSelectOn - override val selectedItems by mainViewModel::selectedThreads - override val publishSelectedItems = mainViewModel::publishSelectedItems + override var isEnabled by multiselectionViewModel::isMultiSelectOn + override val selectedItems by multiselectionViewModel::selectedThreads + override val publishSelectedItems = multiselectionViewModel::publishSelectedItems }, ) @@ -491,10 +497,10 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio multiselectToolbar.cancel.setOnClickListener { trackMultiSelectionEvent(MatomoName.Cancel) - mainViewModel.isMultiSelectOn = false + multiselectionViewModel.isMultiSelectOn = false } multiselectToolbar.selectAll.setOnClickListener { - mainViewModel.selectOrUnselectAll() + multiselectionViewModel.selectOrUnselectAll(mainViewModel.currentThreadsLive) threadListAdapter.updateSelection() } @@ -862,7 +868,7 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio unreadCount, formatUnreadCount(unreadCount) ) - isGone = unreadCount == 0 || mainViewModel.isMultiSelectOn + isGone = unreadCount == 0 || multiselectionViewModel.isMultiSelectOn } } @@ -872,13 +878,13 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio binding.toolbar.title = folderName } - private fun removeMultiSelectItems(deletedIndices: IntArray) = with(mainViewModel) { + private fun removeMultiSelectItems(deletedIndices: IntArray) = with(multiselectionViewModel) { if (isMultiSelectOn) { val previousThreads = threadListAdapter.dataSet.filterIsInstance() var shouldPublish = false deletedIndices.forEach { val thread = previousThreads.getOrElse(it) { return@forEach }.thread - val isRemoved = mainViewModel.selectedThreads.remove(thread) + val isRemoved = selectedThreads.remove(thread) if (isRemoved) shouldPublish = true } if (shouldPublish) publishSelectedItems() diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index 8e2d3c68cb..75423a3ca2 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -17,14 +17,11 @@ */ package com.infomaniak.mail.ui.main.folder -// import com.infomaniak.mail.ui.main.folder.``.computeReadFavoriteStatus import android.transition.AutoTransition import android.transition.TransitionManager import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope -import androidx.navigation.NavDirections import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation.DirectionFlag import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackMultiSelectActionEvent @@ -35,51 +32,24 @@ import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.ui.MainActivity import com.infomaniak.mail.ui.MainViewModel -import com.infomaniak.mail.ui.main.search.SearchViewModel import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionHost +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel +import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.Utils.runCatchingRealm import com.infomaniak.mail.utils.extensions.archiveWithConfirmationPopup import com.infomaniak.mail.utils.extensions.deleteWithConfirmationPopup import kotlinx.coroutines.launch -interface MultiSelectionHost : LifecycleOwner { - val multiSelectionBinding: MultiSelectionBinding - val folderRoleUtils: com.infomaniak.mail.utils.FolderRoleUtils - val descriptionDialog: com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog - val threadListAdapter: ThreadListAdapter - fun safeNavigation(directions: NavDirections) - fun disableSwipeDirection(direction: DirectionFlag) - fun unlockSwipeActionsIfSet() - fun directionToThreadActionsBottomSheetDialog( - threadUid: String, - shouldLoadDistantResources: Boolean, - shouldCloseMultiSelection: Boolean - ): NavDirections - - fun directionsToMultiSelectBottomSheetDialog(): NavDirections -} - -interface MultiSelectionBinding { - val quickActionBar: com.infomaniak.mail.views.BottomQuickActionBarView - val multiselectToolbar: com.infomaniak.mail.databinding.ViewMultiselectionInfoToolbarBinding - val toolbarLayout: android.view.View - val toolbar: android.view.View - val threadsList: android.view.ViewGroup - val newMessageFab: android.view.View? - val unreadCountChip: android.view.View? -} - class ThreadListMultiSelection { lateinit var mainViewModel: MainViewModel - + lateinit var multiselectionViewModel: MultiselectionViewModel lateinit var actionsViewModel: ActionsViewModel - private lateinit var threadListFragment: ThreadListFragment lateinit var mainActivity: MainActivity - lateinit var searchViewModel: SearchViewModel private lateinit var host: MultiSelectionHost - + private lateinit var folderRoleUtils: FolderRoleUtils lateinit var unlockSwipeActionsIfSet: () -> Unit lateinit var localSettings: LocalSettings @@ -88,86 +58,32 @@ class ThreadListMultiSelection { fun initMultiSelection( mainViewModel: MainViewModel, + multiselectionViewModel: MultiselectionViewModel, actionsViewModel: ActionsViewModel, activity: MainActivity, host: MultiSelectionHost, - searchViewModel: SearchViewModel?, + folderRoleUtils: FolderRoleUtils, unlockSwipeActionsIfSet: () -> Unit, localSettings: LocalSettings, ) { this.mainViewModel = mainViewModel + this.multiselectionViewModel = multiselectionViewModel this.actionsViewModel = actionsViewModel this.mainActivity = activity this.host = host + this.folderRoleUtils = folderRoleUtils this.unlockSwipeActionsIfSet = unlockSwipeActionsIfSet this.localSettings = localSettings - if (searchViewModel != null) { - this.searchViewModel = searchViewModel - setupMessageMultiSelectionActions() - } else setupMultiSelectionActions() - + setupMultiSelectionActions() observerMultiSelection() } - private fun setupMessageMultiSelectionActions() = with(searchViewModel) { - host.multiSelectionBinding.quickActionBar.setOnItemClickListener { menuId -> - val selectedMessagesUids = selectedMessages.map { it.uid } - val selectedMessagesCount = selectedMessagesUids.count() - - when (menuId) { - R.id.quickActionUnread -> { - trackMultiSelectActionEvent(MatomoName.MarkAsSeen, selectedMessagesCount) - actionsViewModel.toggleMessagesSeenStatus( - selectedMessages.toList(), - false, - mainViewModel.currentFolderId, - mainViewModel.currentMailbox.value!! - ) - mainViewModel.isMultiSelectOn = false - } - // R.id.quickActionArchive -> host.lifecycleScope.launch { - // trackMultiSelectActionEvent(MatomoName.Archive, selectedMessagesCount) - // mainViewModel.archiveThreads(selectedMessagesUids) - // isMultiSelectOn = false - // } - // R.id.quickActionFavorite -> { - // trackMultiSelectActionEvent(MatomoName.Favorite, selectedThreadsCount) - // toggleThreadsFavoriteStatus(selectedThreadsUids, shouldMultiselectFavorite) - // isMultiSelectOn = false - // } - // R.id.quickActionDelete -> host.lifecycleScope.launch { - // host.descriptionDialog.deleteWithConfirmationPopup( - // folderRole = host.folderRoleUtils.getActionFolderRole(selectedThreads), - // count = selectedThreadsCount, - // ) { - // trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) - // deleteThreads(selectedThreadsUids) - // isMultiSelectOn = false - // } - // } - // R.id.quickActionMenu -> { - // trackMultiSelectActionEvent(MatomoName.OpenBottomSheet, selectedThreadsCount) - // val direction = if (selectedThreadsCount == 1) { - // host.directionToThreadActionsBottomSheetDialog( - // threadUid = selectedThreadsUids.single(), - // shouldLoadDistantResources = false, - // shouldCloseMultiSelection = true, - // ) - // } else { - // host.directionsToMultiSelectBottomSheetDialog() - // } - // host.safeNavigation(direction) - // } - } - } - } - - private fun setupMultiSelectionActions() = with(mainViewModel) { + private fun setupMultiSelectionActions() = with(multiselectionViewModel) { host.multiSelectionBinding.quickActionBar.setOnItemClickListener { menuId -> val selectedThreadsUids = selectedThreads.map { it.uid } val selectedThreadsCount = selectedThreadsUids.count() - val currentMailBox = currentMailbox.value ?: return@setOnItemClickListener + val currentMailBox = mainViewModel.currentMailbox.value ?: return@setOnItemClickListener when (menuId) { R.id.quickActionUnread -> { @@ -175,7 +91,7 @@ class ThreadListMultiSelection { actionsViewModel.toggleThreadsSeenStatus( threadsUids = selectedThreadsUids, shouldRead = shouldMultiselectRead, - currentFolderId = currentFolderId, + currentFolderId = mainViewModel.currentFolderId, mailbox = currentMailBox, ) isMultiSelectOn = false @@ -188,7 +104,7 @@ class ThreadListMultiSelection { trackMultiSelectActionEvent(MatomoName.Archive, selectedThreadsCount) actionsViewModel.archiveThreads( threads = selectedThreads.toList(), - currentFolder = currentFolder.value, + currentFolder = mainViewModel.currentFolder.value, mailbox = currentMailBox, ) isMultiSelectOn = false @@ -234,10 +150,10 @@ class ThreadListMultiSelection { } private fun observerMultiSelection() = with(host) { - mainViewModel.isMultiSelectOnLiveData.observe(host) { isMultiSelectOn -> + multiselectionViewModel.isMultiSelectOnLiveData.observe(host) { isMultiSelectOn -> threadListAdapter.updateSelection() if (localSettings.threadDensity != ThreadDensity.LARGE) TransitionManager.beginDelayedTransition(host.multiSelectionBinding.threadsList) - if (!isMultiSelectOn) mainViewModel.selectedThreads.clear() + if (!isMultiSelectOn) multiselectionViewModel.selectedThreads.clear() displaySelectionToolbar(isMultiSelectOn) lockDrawerAndSwipe(isMultiSelectOn) @@ -245,9 +161,9 @@ class ThreadListMultiSelection { displayMultiSelectActions(isMultiSelectOn) } - mainViewModel.selectedThreadsLiveData.observe(host) { selectedThreads -> + multiselectionViewModel.selectedThreadsLiveData.observe(host) { selectedThreads -> if (selectedThreads.isEmpty()) { - mainViewModel.isMultiSelectOn = false + multiselectionViewModel.isMultiSelectOn = false } else { updateSelectedCount(selectedThreads) updateSelectAllLabel() @@ -297,7 +213,10 @@ class ThreadListMultiSelection { } private fun updateSelectAllLabel() { - val selectAllLabel = if (mainViewModel.isEverythingSelected) R.string.buttonUnselectAll else R.string.buttonSelectAll + val currentThreadsSelected = mainViewModel.currentThreadsLive.value?.list?.count() ?: 0 + val isEverythingSelected = multiselectionViewModel.isEverythingSelected(currentThreadsSelected) + val selectAllLabel = + if (isEverythingSelected) R.string.buttonUnselectAll else R.string.buttonSelectAll host.multiSelectionBinding.multiselectToolbar.selectAll.setText(selectAllLabel) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 8981fcead2..984734697c 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -38,9 +38,9 @@ import androidx.navigation.NavDirections import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy +import com.infomaniak.core.fragmentnavigation.safelyNavigate import com.infomaniak.core.legacy.utils.Utils import com.infomaniak.core.legacy.utils.hideKeyboard -import com.infomaniak.core.legacy.utils.safeNavigate import com.infomaniak.core.legacy.utils.showKeyboard import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation.DirectionFlag @@ -64,8 +64,6 @@ import com.infomaniak.mail.databinding.FragmentSearchBinding import com.infomaniak.mail.ui.MainActivity import com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog import com.infomaniak.mail.ui.main.SnackbarManager -import com.infomaniak.mail.ui.main.folder.MultiSelectionBinding -import com.infomaniak.mail.ui.main.folder.MultiSelectionHost import com.infomaniak.mail.ui.main.folder.MultiSelectionListener import com.infomaniak.mail.ui.main.folder.PerformSwipeActionManager import com.infomaniak.mail.ui.main.folder.ThreadListAdapterCallbacks @@ -76,6 +74,9 @@ import com.infomaniak.mail.ui.main.folder.TwoPaneFragment import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.thread.ThreadFragment import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionBinding +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionHost +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.Utils.Shortcuts import com.infomaniak.mail.utils.Utils.isPermanentDeleteFolder @@ -101,13 +102,16 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { private var _binding: FragmentSearchBinding? = null private val binding get() = _binding!! // This property is only valid between onCreateView and onDestroyView + private val multiselectionViewModel: MultiselectionViewModel by viewModels() + @Inject override lateinit var folderRoleUtils: FolderRoleUtils + @Inject override lateinit var descriptionDialog: DescriptionAlertDialog override fun safeNavigation(directions: NavDirections) { - safeNavigate(directions) + safelyNavigate(directions) } override fun disableSwipeDirection(direction: DirectionFlag) { @@ -115,7 +119,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { } override fun unlockSwipeActionsIfSet() = with(binding.mailRecyclerView) { - val isMultiSelectClosed = mainViewModel.isMultiSelectOn.not() + val isMultiSelectClosed = multiselectionViewModel.isMultiSelectOn.not() val isLeftSet = localSettings.swipeLeft != SwipeAction.NONE val isLeftEnabled = isLeftSet && isMultiSelectClosed @@ -202,9 +206,10 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { threadListMultiSelection.initMultiSelection( mainViewModel = mainViewModel, actionsViewModel = actionsViewModel, + multiselectionViewModel = multiselectionViewModel, host = this, + folderRoleUtils = folderRoleUtils, activity = (requireActivity() as MainActivity), - searchViewModel = searchViewModel, unlockSwipeActionsIfSet = ::unlockSwipeActionsIfSet, localSettings = localSettings, ) @@ -346,9 +351,9 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { override val getFeatureFlags: () -> FeatureFlagSet? = { mainViewModel.featureFlagsLive.value } }, multiSelection = object : MultiSelectionListener { - override var isEnabled by mainViewModel::isMultiSelectOn - override val selectedItems by mainViewModel::selectedThreads - override val publishSelectedItems = mainViewModel::publishSelectedItems + override var isEnabled by multiselectionViewModel::isMultiSelectOn + override val selectedItems by multiselectionViewModel::selectedThreads + override val publishSelectedItems = multiselectionViewModel::publishSelectedItems }, ) @@ -362,10 +367,10 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { } multiselectToolbar.cancel.setOnClickListener { trackMultiSelectionEvent(MatomoName.Cancel) - mainViewModel.isMultiSelectOn = false + multiselectionViewModel.isMultiSelectOn = false } multiselectToolbar.selectAll.setOnClickListener { - mainViewModel.selectOrUnselectAll() + multiselectionViewModel.selectOrUnselectAll(mainViewModel.currentThreadsLive) threadListAdapter.updateSelection() } mailRecyclerView.swipeListener = object : OnItemSwipeListener { @@ -581,13 +586,13 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { searchViewModel.visibilityMode.observe(viewLifecycleOwner, ::updateUi) } - private fun removeMultiSelectItems(deletedIndices: IntArray) = with(mainViewModel) { + private fun removeMultiSelectItems(deletedIndices: IntArray) = with(multiselectionViewModel) { if (isMultiSelectOn) { val previousThreads = threadListAdapter.dataSet.filterIsInstance() var shouldPublish = false deletedIndices.forEach { val thread = previousThreads.getOrElse(it) { return@forEach }.thread - val isRemoved = mainViewModel.selectedThreads.remove(thread) + val isRemoved = selectedThreads.remove(thread) if (isRemoved) shouldPublish = true } if (shouldPublish) publishSelectedItems() @@ -649,7 +654,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { } private fun observeMultiSelect() { - mainViewModel.isMultiSelectOnLiveData.observe(viewLifecycleOwner) { isMultiSelectOn -> + multiselectionViewModel.isMultiSelectOnLiveData.observe(viewLifecycleOwner) { isMultiSelectOn -> val autoTransition = AutoTransition() autoTransition.duration = TOOLBAR_FADE_DURATION TransitionManager.beginDelayedTransition(binding.horizontalScrollViewFilters, autoTransition) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index a508a5016c..b6399b7d24 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -93,8 +93,6 @@ class SearchViewModel @Inject constructor( inline val selectedMessages get() = selectedMessagesLiveData.value!! - val isEveryMessageSelected - get() = runCatchingRealm { selectedMessages.count() == searchResults.value?.list?.count() }.getOrDefault(false) var filterFolder: Folder? = null private set var currentSearchQuery: String = "" diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/DetailedContactBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/DetailedContactBottomSheetDialog.kt index a7d540b02d..78ecddaf08 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/DetailedContactBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/DetailedContactBottomSheetDialog.kt @@ -32,6 +32,7 @@ import com.infomaniak.mail.databinding.BottomSheetDetailedContactBinding import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.thread.actions.ActionsBottomSheetDialog +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.ui.newMessage.NewMessageActivityArgs import com.infomaniak.mail.utils.extensions.copyRecipientEmailToClipboard import com.infomaniak.mail.utils.extensions.safeNavigateToNewMessageActivity @@ -43,7 +44,8 @@ class DetailedContactBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetDetailedContactBinding by safeBinding() private val navigationArgs: DetailedContactBottomSheetDialogArgs by navArgs() - override val mainViewModel: MainViewModel by activityViewModels() + override val multiselectionViewModel: MultiselectionViewModel by activityViewModels() + val mainViewModel: MainViewModel by activityViewModels() private val currentClassName: String by lazy { DetailedContactBottomSheetDialog::class.java.name } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsBottomSheetDialog.kt index 23357d29c3..b837a7df3b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsBottomSheetDialog.kt @@ -18,16 +18,16 @@ package com.infomaniak.mail.ui.main.thread.actions import androidx.navigation.fragment.findNavController -import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.bottomSheetDialogs.EdgeToEdgeBottomSheetDialog +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel abstract class ActionsBottomSheetDialog : EdgeToEdgeBottomSheetDialog() { - abstract val mainViewModel: MainViewModel? + abstract val multiselectionViewModel: MultiselectionViewModel? protected fun ActionItemView.setClosingOnClickListener(shouldCloseMultiSelection: Boolean = false, callback: () -> Unit) { setOnClickListener { - if (shouldCloseMultiSelection) mainViewModel?.isMultiSelectOn = false + if (shouldCloseMultiSelection) multiselectionViewModel?.isMultiSelectOn = false findNavController().popBackStack() callback() } @@ -35,7 +35,7 @@ abstract class ActionsBottomSheetDialog : EdgeToEdgeBottomSheetDialog() { protected fun MainActionsView.setClosingOnClickListener(shouldCloseMultiSelection: Boolean = false, callback: (Int) -> Unit) { setOnItemClickListener { id -> - if (shouldCloseMultiSelection) mainViewModel?.isMultiSelectOn = false + if (shouldCloseMultiSelection) multiselectionViewModel?.isMultiSelectOn = false findNavController().popBackStack() callback(id) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/AttachmentActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/AttachmentActionsBottomSheetDialog.kt index 05cae1db6a..054b22883d 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/AttachmentActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/AttachmentActionsBottomSheetDialog.kt @@ -35,6 +35,7 @@ import com.infomaniak.mail.data.models.SwissTransferFile import com.infomaniak.mail.databinding.BottomSheetAttachmentActionsBinding import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.main.SnackbarManager +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.utils.PermissionUtils import com.infomaniak.mail.utils.extensions.AttachmentExt.AttachmentIntentType import com.infomaniak.mail.utils.extensions.AttachmentExt.executeIntent @@ -47,7 +48,8 @@ import javax.inject.Inject class AttachmentActionsBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetAttachmentActionsBinding by safeBinding() - override val mainViewModel: MainViewModel by activityViewModels() + override val multiselectionViewModel: MultiselectionViewModel by activityViewModels() + val mainViewModel: MainViewModel by activityViewModels() private val attachmentActionsViewModel: AttachmentActionsViewModel by viewModels() @Inject diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt index 0dd491d81a..5d2e4fca59 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt @@ -41,7 +41,7 @@ abstract class MailActionsBottomSheetDialog : ActionsBottomSheetDialog() { protected var binding: BottomSheetActionsMenuBinding by safeBinding() - override val mainViewModel: MainViewModel by activityViewModels() + val mainViewModel: MainViewModel by activityViewModels() protected val twoPaneViewModel: TwoPaneViewModel by activityViewModels() abstract val shouldCloseMultiSelection: Boolean diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt index a13e8400de..7a7cb6f843 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt @@ -45,6 +45,7 @@ import com.infomaniak.mail.ui.main.thread.PrintMailFragmentArgs import com.infomaniak.mail.ui.main.thread.ThreadFragment.Companion.OPEN_REACTION_BOTTOM_SHEET import com.infomaniak.mail.ui.main.thread.actions.ThreadActionsBottomSheetDialog.Companion.setBlockUserUi import com.infomaniak.mail.ui.main.thread.actions.ThreadActionsBottomSheetDialog.Companion.setSpamUi +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.extensions.animatedNavigation @@ -64,6 +65,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { private val navigationArgs: MessageActionsBottomSheetDialogArgs by navArgs() + override val multiselectionViewModel: MultiselectionViewModel by activityViewModels() private val actionsViewModel: ActionsViewModel by activityViewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index f0d803fcc1..3c4bc32d19 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -54,6 +54,7 @@ import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType import com.infomaniak.mail.ui.main.thread.actions.ThreadActionsBottomSheetDialog.Companion.OPEN_SNOOZE_BOTTOM_SHEET import com.infomaniak.mail.ui.main.thread.actions.ThreadActionsBottomSheetDialog.Companion.setBlockUserUi +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.extensions.animatedNavigation @@ -72,7 +73,8 @@ import com.infomaniak.core.common.R as RCore class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetMultiSelectBinding by safeBinding() - override val mainViewModel: MainViewModel by activityViewModels() + val mainViewModel: MainViewModel by activityViewModels() + override val multiselectionViewModel: MultiselectionViewModel by activityViewModels() private val actionsViewModel: ActionsViewModel by activityViewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() @@ -95,7 +97,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { return BottomSheetMultiSelectBinding.inflate(inflater, container, false).also { binding = it }.root } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(mainViewModel) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(multiselectionViewModel) { super.onViewCreated(view, savedInstanceState) // This `.toSet()` is used to make an immutable local copy of `selectedThreads`. diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ReplyBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ReplyBottomSheetDialog.kt index 9d86cb6313..cf57f90205 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ReplyBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ReplyBottomSheetDialog.kt @@ -21,6 +21,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.navArgs import com.infomaniak.core.legacy.utils.safeBinding import com.infomaniak.mail.MatomoMail.MatomoCategory @@ -30,12 +31,14 @@ import com.infomaniak.mail.R import com.infomaniak.mail.data.models.draft.Draft.DraftMode import com.infomaniak.mail.databinding.BottomSheetReplyBinding import com.infomaniak.mail.ui.MainViewModel +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.utils.extensions.safeNavigateToNewMessageActivity open class ReplyBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetReplyBinding by safeBinding() - override val mainViewModel: MainViewModel? = null + val mainViewModel: MainViewModel? = null + override val multiselectionViewModel: MultiselectionViewModel by activityViewModels() private val navigationArgs: ReplyBottomSheetDialogArgs by navArgs() private val currentClassName: String by lazy { ReplyBottomSheetDialog::class.java.name } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt index 8a8afebcd8..d77a8a1be3 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt @@ -50,6 +50,7 @@ import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.folderPicker.FolderPickerFragmentArgs import com.infomaniak.mail.ui.main.thread.ThreadFragment.Companion.OPEN_REACTION_BOTTOM_SHEET import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.extensions.animatedNavigation @@ -69,6 +70,7 @@ import com.infomaniak.core.common.R as RCore class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { private val navigationArgs: ThreadActionsBottomSheetDialogArgs by navArgs() + override val multiselectionViewModel: MultiselectionViewModel by activityViewModels() private val threadActionsViewModel: ThreadActionsViewModel by viewModels() private val actionsViewModel: ActionsViewModel by activityViewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() @@ -301,7 +303,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { }, ) - mainViewModel.isMultiSelectOn = false + multiselectionViewModel.isMultiSelectOn = false } override fun onBlockSender() { @@ -323,7 +325,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { junkMessagesViewModel.messageOfUserToBlock.value = message } } - mainViewModel.isMultiSelectOn = false + multiselectionViewModel.isMultiSelectOn = false } override fun onPrint() { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/UserToBlockBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/UserToBlockBottomSheetDialog.kt index 89eafe9c80..6d9aa0ebbd 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/UserToBlockBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/UserToBlockBottomSheetDialog.kt @@ -26,12 +26,14 @@ import androidx.navigation.fragment.findNavController import com.infomaniak.core.legacy.utils.safeBinding import com.infomaniak.mail.databinding.BottomSheetUserToBlockBinding import com.infomaniak.mail.ui.MainViewModel +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel class UserToBlockBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetUserToBlockBinding by safeBinding() - override val mainViewModel: MainViewModel by activityViewModels() + val mainViewModel: MainViewModel by activityViewModels() + override val multiselectionViewModel: MultiselectionViewModel by activityViewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionBinding.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionBinding.kt new file mode 100644 index 0000000000..8ba82b81cd --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionBinding.kt @@ -0,0 +1,29 @@ +/* + * 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.thread.actions.multiselection + +interface MultiSelectionBinding { + val quickActionBar: com.infomaniak.mail.views.BottomQuickActionBarView + val multiselectToolbar: com.infomaniak.mail.databinding.ViewMultiselectionInfoToolbarBinding + val toolbarLayout: android.view.View + val toolbar: android.view.View + val threadsList: android.view.ViewGroup + val newMessageFab: android.view.View? + val unreadCountChip: android.view.View? +} + diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt new file mode 100644 index 0000000000..4e64af1866 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt @@ -0,0 +1,40 @@ +/* + * 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.thread.actions.multiselection + +import androidx.lifecycle.LifecycleOwner +import androidx.navigation.NavDirections +import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation.DirectionFlag +import com.infomaniak.mail.ui.main.folder.ThreadListAdapter + +interface MultiSelectionHost : LifecycleOwner { + val multiSelectionBinding: MultiSelectionBinding + val folderRoleUtils: com.infomaniak.mail.utils.FolderRoleUtils + val descriptionDialog: com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog + val threadListAdapter: ThreadListAdapter + fun safeNavigation(directions: NavDirections) + fun disableSwipeDirection(direction: DirectionFlag) + fun unlockSwipeActionsIfSet() + fun directionToThreadActionsBottomSheetDialog( + threadUid: String, + shouldLoadDistantResources: Boolean, + shouldCloseMultiSelection: Boolean + ): NavDirections + + fun directionsToMultiSelectBottomSheetDialog(): NavDirections +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt new file mode 100644 index 0000000000..d479a617d5 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt @@ -0,0 +1,65 @@ +/* + * 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.thread.actions.multiselection + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import com.infomaniak.mail.MatomoMail.MatomoName +import com.infomaniak.mail.MatomoMail.trackMultiSelectionEvent +import com.infomaniak.mail.data.models.thread.Thread +import dagger.hilt.android.lifecycle.HiltViewModel +import io.realm.kotlin.notifications.ResultsChange +import javax.inject.Inject + +@HiltViewModel +class MultiselectionViewModel @Inject constructor( + application: Application, +) : AndroidViewModel(application) { + val isMultiSelectOnLiveData = MutableLiveData(false) + inline var isMultiSelectOn + get() = isMultiSelectOnLiveData.value!! + set(value) { + isMultiSelectOnLiveData.value = value + } + + val selectedThreadsLiveData = MutableLiveData(mutableSetOf()) + inline val selectedThreads + get() = selectedThreadsLiveData.value!! + + fun isEverythingSelected(currentThreadCount: Int): Boolean { + return selectedThreads.count() == currentThreadCount + } + + fun selectOrUnselectAll(currentThreadsLive: MutableLiveData>) { + val isEverythingSelected = isEverythingSelected(currentThreadsLive.value?.list?.count() ?: 0) + if (isEverythingSelected) { + trackMultiSelectionEvent(MatomoName.None) + selectedThreads.clear() + } else { + trackMultiSelectionEvent(MatomoName.All) + currentThreadsLive.value?.list?.forEach { thread -> selectedThreads.add(thread) } + } + + publishSelectedItems() + } + + fun publishSelectedItems() { + selectedThreadsLiveData.value = selectedThreads + } +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/encryption/UnencryptableRecipientsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/encryption/UnencryptableRecipientsBottomSheetDialog.kt index d7e2dfbcf6..1011b79170 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/encryption/UnencryptableRecipientsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/encryption/UnencryptableRecipientsBottomSheetDialog.kt @@ -35,7 +35,7 @@ class UnencryptableRecipientsBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetAttendeesBinding by safeBinding() private val navigationArgs: UnencryptableRecipientsBottomSheetDialogArgs by navArgs() - override val mainViewModel = null + override val multiselectionViewModel = null override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { return BottomSheetAttendeesBinding.inflate(inflater, container, false).also { binding = it }.root diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/encryption/EncryptionActionsBottomSheet.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/encryption/EncryptionActionsBottomSheet.kt index 3d77a8aea2..df53f420e1 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/encryption/EncryptionActionsBottomSheet.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/encryption/EncryptionActionsBottomSheet.kt @@ -51,7 +51,7 @@ class EncryptionActionsBottomSheet : ActionsBottomSheetDialog() { private val encryptionViewModel: EncryptionViewModel by activityViewModels() private val newMessageViewModel: NewMessageViewModel by activityViewModels() - override val mainViewModel = null + override val multiselectionViewModel = null @Inject lateinit var snackbarManager: SnackbarManager From aca1a7a2ccab7049434dc1d3969c6131fb96c5bf Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Wed, 20 May 2026 16:05:37 +0200 Subject: [PATCH 09/52] feat: Actions multiselect in search --- .../mail/ui/main/folder/ThreadListFragment.kt | 28 +++++-- .../main/folder/ThreadListMultiSelection.kt | 80 +++++++++++++++++-- .../mail/ui/main/search/SearchFragment.kt | 56 ++++++++----- .../mail/ui/main/search/SearchViewModel.kt | 14 ++-- .../actions/MultiSelectBottomSheetDialog.kt | 38 ++++++--- .../actions/ThreadActionsBottomSheetDialog.kt | 6 +- .../multiselection/MultiselectionHost.kt | 13 ++- .../multiselection/MultiselectionViewModel.kt | 18 +++++ app/src/main/res/layout/fragment_search.xml | 2 +- .../main/res/navigation/main_navigation.xml | 8 ++ 10 files changed, 209 insertions(+), 54 deletions(-) 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 7f1c093ec3..8fca1c5890 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 @@ -92,6 +92,7 @@ import com.infomaniak.mail.ui.main.emojiPicker.EmojiPickerBottomSheetDialog.Emoj import com.infomaniak.mail.ui.main.emojiPicker.PickedEmojiPayload import com.infomaniak.mail.ui.main.emojiPicker.PickerEmojiObserver import com.infomaniak.mail.ui.main.folder.ThreadListViewModel.ContentDisplayMode +import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.thread.ThreadFragment import com.infomaniak.mail.ui.main.thread.actions.EmojiReactionsViewModel import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionBinding @@ -137,7 +138,7 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio private val navigationArgs: ThreadListFragmentArgs by navArgs() private val threadListViewModel: ThreadListViewModel by viewModels() - private val multiselectionViewModel: MultiselectionViewModel by viewModels() + private val multiselectionViewModel: MultiselectionViewModel by activityViewModels() private val emojiReactionsViewModel: EmojiReactionsViewModel by viewModels() override val substituteClassName: String = javaClass.name @@ -215,6 +216,8 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio folderRoleUtils = folderRoleUtils, unlockSwipeActionsIfSet = ::unlockSwipeActionsIfSet, localSettings = localSettings, + searchViewModel = null, + isFromSearch = false, ) observeNetworkStatus() @@ -375,17 +378,32 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio override fun directionToThreadActionsBottomSheetDialog( threadUid: String, shouldLoadDistantResources: Boolean, - shouldCloseMultiSelection: Boolean + shouldCloseMultiSelection: Boolean, + isFromSearch: Boolean, ): NavDirections { return ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( threadUid, shouldLoadDistantResources, - shouldCloseMultiSelection + shouldCloseMultiSelection, + isFromSearch ) } - override fun directionsToMultiSelectBottomSheetDialog(): NavDirections { - return ThreadListFragmentDirections.actionThreadListFragmentToMultiSelectBottomSheetDialog() + override fun directionsToMultiSelectBottomSheetDialog(isFromSearch: Boolean): NavDirections { + return ThreadListFragmentDirections.actionThreadListFragmentToMultiSelectBottomSheetDialog(isFromSearch) + } + + override fun directionsToFolderPickerFragment( + threadsUids: Array, + messagesUids: Array?, + action: FolderPickerAction, + sourceFolderId: String? + ): NavDirections { + return ThreadListFragmentDirections.actionThreadListFragmentToFolderPickerFragment( + threadsUids, + messagesUids, + FolderPickerAction.MOVE, sourceFolderId + ) } private fun setupDensityDependentUi() = with(binding) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index 75423a3ca2..e0dead2cec 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -32,6 +32,7 @@ import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.ui.MainActivity import com.infomaniak.mail.ui.MainViewModel +import com.infomaniak.mail.ui.main.search.SearchViewModel import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionHost import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel @@ -46,13 +47,14 @@ class ThreadListMultiSelection { lateinit var mainViewModel: MainViewModel lateinit var multiselectionViewModel: MultiselectionViewModel lateinit var actionsViewModel: ActionsViewModel + lateinit var searchViewModel: SearchViewModel lateinit var mainActivity: MainActivity private lateinit var host: MultiSelectionHost private lateinit var folderRoleUtils: FolderRoleUtils + private var isFromSearch: Boolean = false lateinit var unlockSwipeActionsIfSet: () -> Unit lateinit var localSettings: LocalSettings - private var shouldMultiselectRead: Boolean = false private var shouldMultiselectFavorite: Boolean = true @@ -65,6 +67,8 @@ class ThreadListMultiSelection { folderRoleUtils: FolderRoleUtils, unlockSwipeActionsIfSet: () -> Unit, localSettings: LocalSettings, + isFromSearch: Boolean, + searchViewModel: SearchViewModel?, ) { this.mainViewModel = mainViewModel this.multiselectionViewModel = multiselectionViewModel @@ -74,11 +78,77 @@ class ThreadListMultiSelection { this.folderRoleUtils = folderRoleUtils this.unlockSwipeActionsIfSet = unlockSwipeActionsIfSet this.localSettings = localSettings + this.isFromSearch = isFromSearch + + if (isFromSearch && searchViewModel != null) { + this.searchViewModel = searchViewModel + setupMessageMultiSelectionActions() + } else { + setupMultiSelectionActions() + } - setupMultiSelectionActions() observerMultiSelection() } + private fun setupMessageMultiSelectionActions() = with(multiselectionViewModel) { + host.multiSelectionBinding.quickActionBar.setOnItemClickListener { menuId -> + val selectedThreadsCount = selectedThreads.count() + val selectedThreadsUids = selectedThreads.map { it.uid } + val selectedMessagesUids = selectedMessages.map { it.uid } + val selectedMessagesCount = selectedMessagesUids.count() + val mailbox = mainViewModel.currentMailbox.value ?: return@setOnItemClickListener + val currentFolderId = mainViewModel.currentFolderId + val currentFolder = mainViewModel.currentFolder.value + + when (menuId) { + R.id.quickActionUnread -> { + trackMultiSelectActionEvent(MatomoName.MarkAsSeen, selectedMessagesCount) + actionsViewModel.toggleMessagesSeenStatus(selectedMessages, shouldMultiselectRead, currentFolderId, mailbox) + val hasSeenFilter = searchViewModel.currentFilters.contains(Thread.ThreadFilter.UNSEEN) + || searchViewModel.currentFilters.contains(Thread.ThreadFilter.SEEN) + if (hasSeenFilter) searchViewModel.refreshSearch() + isMultiSelectOn = false + } + R.id.quickActionArchive -> host.lifecycleScope.launch { + trackMultiSelectActionEvent(MatomoName.Archive, selectedMessagesCount) + actionsViewModel.archiveMessages(selectedMessages, currentFolder, mailbox) + searchViewModel.refreshSearch() + isMultiSelectOn = false + } + R.id.quickActionFavorite -> { + trackMultiSelectActionEvent(MatomoName.Favorite, selectedThreadsCount) + actionsViewModel.toggleMessagesFavoriteStatus(selectedMessages, shouldMultiselectFavorite, mailbox) + if (searchViewModel.currentFilters.contains(Thread.ThreadFilter.STARRED)) searchViewModel.refreshSearch() + isMultiSelectOn = false + } + R.id.quickActionDelete -> host.lifecycleScope.launch { + host.descriptionDialog.deleteWithConfirmationPopup( + folderRoles = host.folderRoleUtils.getActionFolderRoles(selectedMessages), + count = selectedThreads.count(), + ) { + trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) + actionsViewModel.deleteMessages(selectedMessages, currentFolder, mailbox) + isMultiSelectOn = false + } + } + R.id.quickActionMenu -> { + trackMultiSelectActionEvent(MatomoName.OpenBottomSheet, selectedThreadsCount) + val direction = if (selectedThreadsCount == 1) { + host.directionToThreadActionsBottomSheetDialog( + threadUid = selectedThreadsUids.single(), + shouldLoadDistantResources = false, + shouldCloseMultiSelection = false, + isFromSearch = isFromSearch + ) + } else { + host.directionsToMultiSelectBottomSheetDialog(isFromSearch) + } + host.safeNavigation(direction) + } + } + } + } + private fun setupMultiSelectionActions() = with(multiselectionViewModel) { host.multiSelectionBinding.quickActionBar.setOnItemClickListener { menuId -> val selectedThreadsUids = selectedThreads.map { it.uid } @@ -134,14 +204,14 @@ class ThreadListMultiSelection { R.id.quickActionMenu -> { trackMultiSelectActionEvent(MatomoName.OpenBottomSheet, selectedThreadsCount) val direction = if (selectedThreadsCount == 1) { - isMultiSelectOn = false host.directionToThreadActionsBottomSheetDialog( threadUid = selectedThreadsUids.single(), shouldLoadDistantResources = false, - shouldCloseMultiSelection = true, + shouldCloseMultiSelection = false, + isFromSearch = isFromSearch, ) } else { - host.directionsToMultiSelectBottomSheetDialog() + host.directionsToMultiSelectBottomSheetDialog(isFromSearch) } host.safeNavigation(direction) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 984734697c..4eb3f9e8d5 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -102,7 +102,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { private var _binding: FragmentSearchBinding? = null private val binding get() = _binding!! // This property is only valid between onCreateView and onDestroyView - private val multiselectionViewModel: MultiselectionViewModel by viewModels() + private val multiselectionViewModel: MultiselectionViewModel by activityViewModels() @Inject override lateinit var folderRoleUtils: FolderRoleUtils @@ -133,17 +133,33 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { override fun directionToThreadActionsBottomSheetDialog( threadUid: String, shouldLoadDistantResources: Boolean, - shouldCloseMultiSelection: Boolean + shouldCloseMultiSelection: Boolean, + isFromSearch: Boolean, ): NavDirections { return SearchFragmentDirections.actionSearchFragmentToThreadActionsBottomSheetDialog( threadUid, shouldLoadDistantResources, - shouldCloseMultiSelection + shouldCloseMultiSelection, + isFromSearch, ) } - override fun directionsToMultiSelectBottomSheetDialog(): NavDirections { - return SearchFragmentDirections.actionSearchFragmentToMultiSelectBottomSheetDialog() + override fun directionsToMultiSelectBottomSheetDialog(isFromSearch: Boolean): NavDirections { + return SearchFragmentDirections.actionSearchFragmentToMultiSelectBottomSheetDialog(isFromSearch) + } + + override fun directionsToFolderPickerFragment( + threadsUids: Array, + messagesUids: Array?, + action: FolderPickerAction, + sourceFolderId: String? + ): NavDirections { + return SearchFragmentDirections.actionSearchFragmentToFolderPickerFragment( + threadsUids, + messagesUids, + FolderPickerAction.MOVE, + sourceFolderId + ) } @Inject @@ -212,6 +228,8 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { activity = (requireActivity() as MainActivity), unlockSwipeActionsIfSet = ::unlockSwipeActionsIfSet, localSettings = localSettings, + searchViewModel = searchViewModel, + isFromSearch = true, ) setAllFoldersButtonListener() @@ -370,8 +388,10 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { multiselectionViewModel.isMultiSelectOn = false } multiselectToolbar.selectAll.setOnClickListener { - multiselectionViewModel.selectOrUnselectAll(mainViewModel.currentThreadsLive) - threadListAdapter.updateSelection() + lifecycleScope.launch { + multiselectionViewModel.selectOrUnselectAllSearchItems(searchViewModel.threadsSearchResults) + threadListAdapter.updateSelection() + } } mailRecyclerView.swipeListener = object : OnItemSwipeListener { override fun onItemSwiped(position: Int, direction: SwipeDirection, item: ThreadListItem.Content): Boolean { @@ -404,7 +424,9 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { } } - swipeRefreshLayout.setOnRefreshListener { searchViewModel.refreshSearch() } + swipeRefreshLayout.setOnRefreshListener { + searchViewModel.refreshSearch(withContacts = !multiselectionViewModel.isMultiSelectOn) + } } /** @@ -586,19 +608,6 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { searchViewModel.visibilityMode.observe(viewLifecycleOwner, ::updateUi) } - private fun removeMultiSelectItems(deletedIndices: IntArray) = with(multiselectionViewModel) { - if (isMultiSelectOn) { - val previousThreads = threadListAdapter.dataSet.filterIsInstance() - var shouldPublish = false - deletedIndices.forEach { - val thread = previousThreads.getOrElse(it) { return@forEach }.thread - val isRemoved = selectedThreads.remove(thread) - if (isRemoved) shouldPublish = true - } - if (shouldPublish) publishSelectedItems() - } - } - private fun updateUi(mode: VisibilityMode) = with(binding) { fun displayRecentSearches() { @@ -655,6 +664,11 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { private fun observeMultiSelect() { multiselectionViewModel.isMultiSelectOnLiveData.observe(viewLifecycleOwner) { isMultiSelectOn -> + if (isMultiSelectOn) { + searchViewModel.contactsResults.value = emptyList() + } else { + searchViewModel.refreshSearch(withContacts = true) + } val autoTransition = AutoTransition() autoTransition.duration = TOOLBAR_FADE_DURATION TransitionManager.beginDelayedTransition(binding.horizontalScrollViewFilters, autoTransition) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index b6399b7d24..b934cf4aed 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -39,7 +39,6 @@ import com.infomaniak.mail.data.cache.userInfo.MergedContactController import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.Companion.DUMMY_FOLDER_ID import com.infomaniak.mail.data.models.correspondent.MergedContact -import com.infomaniak.mail.data.models.message.Message import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.di.IoDispatcher @@ -89,10 +88,6 @@ class SearchViewModel @Inject constructor( private val dummyFolderId inline get() = savedStateHandle.get(SearchFragmentArgs::dummyFolderId.name) ?: DUMMY_FOLDER_ID - val selectedMessagesLiveData = MutableLiveData(mutableSetOf()) - inline val selectedMessages - get() = selectedMessagesLiveData.value!! - var filterFolder: Folder? = null private set var currentSearchQuery: String = "" @@ -100,7 +95,7 @@ class SearchViewModel @Inject constructor( private var currentUiState: SearchUiState = SearchUiState.IDLE - private var currentFilters = mutableSetOf() + var currentFilters = mutableSetOf() var isAllFoldersSelected: Boolean = false private var lastExecutedFolder: Folder? = null @@ -146,8 +141,8 @@ class SearchViewModel @Inject constructor( resetFolderFilter() } - fun refreshSearch() = viewModelScope.launch(ioCoroutineContext) { - search() + fun refreshSearch(withContacts: Boolean = true) = viewModelScope.launch(ioCoroutineContext) { + search(withContacts = withContacts) } fun searchQuery(query: String, saveInHistory: Boolean = false) = viewModelScope.launch(ioCoroutineContext) { @@ -335,6 +330,7 @@ class SearchViewModel @Inject constructor( filters: Set = currentFilters, folder: Folder? = filterFolder, shouldGetNextPage: Boolean = false, + withContacts: Boolean = true, ) = withContext(ioCoroutineContext) { cancelSearch() @@ -345,7 +341,7 @@ class SearchViewModel @Inject constructor( val showContacts = shouldShowContacts() && query.isNotBlank() && !query.contains("\"") && - !isLengthTooShort(query) + !isLengthTooShort(query) && withContacts val contacts = if (showContacts) { val searchQueryNoAccents = Normalizer.normalize(query, Normalizer.Form.NFD) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index 3c4bc32d19..8c54f14282 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -28,6 +28,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs import com.infomaniak.core.fragmentnavigation.safelyNavigate import com.infomaniak.core.legacy.utils.safeBinding import com.infomaniak.core.legacy.utils.setBackNavigationResult @@ -51,6 +52,8 @@ import com.infomaniak.mail.ui.main.folder.ThreadListMultiSelection.Companion.get import com.infomaniak.mail.ui.main.folder.ThreadListMultiSelection.Companion.getFavoriteIconAndShortText import com.infomaniak.mail.ui.main.folder.ThreadListMultiSelection.Companion.getReadIconAndShortText import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction +import com.infomaniak.mail.ui.main.search.SearchFragmentDirections +import com.infomaniak.mail.ui.main.search.SearchViewModel import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType import com.infomaniak.mail.ui.main.thread.actions.ThreadActionsBottomSheetDialog.Companion.OPEN_SNOOZE_BOTTOM_SHEET import com.infomaniak.mail.ui.main.thread.actions.ThreadActionsBottomSheetDialog.Companion.setBlockUserUi @@ -60,6 +63,7 @@ import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.extensions.animatedNavigation import com.infomaniak.mail.utils.extensions.archiveWithConfirmationPopup import com.infomaniak.mail.utils.extensions.deleteWithConfirmationPopup +import com.infomaniak.mail.utils.extensions.getUids import com.infomaniak.mail.utils.extensions.moveWithConfirmationPopup import com.infomaniak.mail.utils.extensions.navigateToDownloadMessagesProgressDialog import dagger.hilt.android.AndroidEntryPoint @@ -73,10 +77,12 @@ import com.infomaniak.core.common.R as RCore class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetMultiSelectBinding by safeBinding() + private val navigationArgs: MultiSelectBottomSheetDialogArgs by navArgs() val mainViewModel: MainViewModel by activityViewModels() override val multiselectionViewModel: MultiselectionViewModel by activityViewModels() private val actionsViewModel: ActionsViewModel by activityViewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() + private val searchViewModel: SearchViewModel by activityViewModels() @Inject lateinit var descriptionDialog: DescriptionAlertDialog @@ -117,7 +123,6 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { val (shouldRead, shouldFavorite) = ThreadListMultiSelection.computeReadFavoriteStatus(threads) val isFromArchive = mainViewModel.currentFolder.value?.role == FolderRole.ARCHIVE - lifecycleScope.launch { val folderRole = folderRoleUtils.getThreadsActionFolderRole(threads) val messages = threads.flatMap { it.messages } @@ -132,20 +137,17 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.snooze.setOnClickListener { trackMultiSelectActionEvent(MatomoName.Snooze, threadsCount, isFromBottomSheet = true) - isMultiSelectOn = false setBackNavigationResult(OPEN_SNOOZE_BOTTOM_SHEET, SnoozeScheduleType.Snooze(threadsUids)) } binding.modifySnooze.setOnClickListener { trackMultiSelectActionEvent(MatomoName.ModifySnooze, threadsCount, isFromBottomSheet = true) - isMultiSelectOn = false setBackNavigationResult(OPEN_SNOOZE_BOTTOM_SHEET, SnoozeScheduleType.Modify(threadsUids)) } binding.cancelSnooze.setClosingOnClickListener { trackMultiSelectActionEvent(MatomoName.CancelSnooze, threadsCount, isFromBottomSheet = true) lifecycleScope.launch { actionsViewModel.unsnoozeThreads(threads.toList(), mainViewModel.currentMailbox.value) } - isMultiSelectOn = false } binding.spam.setClosingOnClickListener { @@ -211,8 +213,6 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { mailbox = currentMailbox, shouldFavorite = shouldFavorite, ) - - isMultiSelectOn = false } binding.saveKDrive.setClosingOnClickListener(shouldCloseMultiSelection = true) { @@ -221,7 +221,6 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { messageUids = threads.flatMap { it.messages }.map { it.uid }, currentClassName = MultiSelectBottomSheetDialog::class.java.name, ) - isMultiSelectOn = false } } @@ -232,7 +231,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { folderRole: FolderRole?, messagesFolderRoles: List ) { - binding.mainActions.setClosingOnClickListener(shouldCloseMultiSelection = true) { id: Int -> + binding.mainActions.setClosingOnClickListener(shouldCloseMultiSelection = false) { id: Int -> val currentMailbox = mainViewModel.currentMailbox.value ?: run { SentryLog.e(TAG, getString(R.string.sentryErrorMailboxIsNull)) { scope -> scope.setTag("context", "$TAG.setupMailAction") @@ -254,6 +253,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { currentFolderId = currentFolderId, mailbox = currentMailbox, ) + multiselectionViewModel.isMultiSelectOn = false } R.id.actionArchive -> { descriptionDialog.archiveWithConfirmationPopup( @@ -266,6 +266,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { currentFolder = currentFolder, mailbox = currentMailbox, ) + multiselectionViewModel.isMultiSelectOn = false } } R.id.actionDelete -> { @@ -280,6 +281,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { currentFolder = currentFolder, mailbox = currentMailbox, ) + multiselectionViewModel.isMultiSelectOn = false } } } @@ -291,15 +293,31 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { threadsUids: List, folderRole: FolderRole? ) { - val navController = findNavController() descriptionDialog.moveWithConfirmationPopup( folderRole = folderRole, count = threadsCount, ) { trackMultiSelectActionEvent(MatomoName.Move, threadsCount, isFromBottomSheet = true) + navigateToFolderPickerFragment(threadsUids) + } + } + + private fun navigateToFolderPickerFragment(threadsUids: List) { + val navController = findNavController() + if (navigationArgs.isFromSearch) { + navController.animatedNavigation( + directions = SearchFragmentDirections.actionSearchFragmentToFolderPickerFragment( + threadsUids = threadsUids.toTypedArray(), + messagesUids = multiselectionViewModel.selectedMessages.getUids().toTypedArray(), + action = FolderPickerAction.MOVE, + sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID, + ) + ) + } else { navController.animatedNavigation( directions = ThreadListFragmentDirections.actionThreadListFragmentToFolderPickerFragment( threadsUids = threadsUids.toTypedArray(), + messagesUids = multiselectionViewModel.selectedMessages.getUids().toTypedArray(), action = FolderPickerAction.MOVE, sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID, ), @@ -344,7 +362,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { private fun setSnoozeUi(threads: Set) = with(binding) { fun hasMixedSnoozeState(): Boolean { - val isFirstThreadSnoozed = threads.first().isSnoozed() + val isFirstThreadSnoozed = threads.firstOrNull()?.isSnoozed() ?: false return threads.any { it.isSnoozed() != isFirstThreadSnoozed } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt index d77a8a1be3..1bb99388c7 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt @@ -77,7 +77,6 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { private val currentClassName: String by lazy { ThreadActionsBottomSheetDialog::class.java.name } override val shouldCloseMultiSelection by lazy { navigationArgs.shouldCloseMultiSelection } - private var folderRole: FolderRole? = null private var messagesFolderRoles: List = emptyList() private var isFromArchive: Boolean = false @@ -215,6 +214,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! ) + multiselectionViewModel.isMultiSelectOn = false } } @@ -225,6 +225,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!!, ) + multiselectionViewModel.isMultiSelectOn = false twoPaneViewModel.closeThread() } @@ -240,6 +241,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID ).toBundle(), ) + multiselectionViewModel.isMultiSelectOn = false } } @@ -270,6 +272,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { threadsUids = listOf(navigationArgs.threadUid), mailbox = mainViewModel.currentMailbox.value!!, ) + multiselectionViewModel.isMultiSelectOn = false } override fun onSpam() { @@ -279,6 +282,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!!, ) + multiselectionViewModel.isMultiSelectOn = false } override fun onPhishing() { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt index 4e64af1866..2de24de46d 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.navigation.NavDirections import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation.DirectionFlag import com.infomaniak.mail.ui.main.folder.ThreadListAdapter +import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction interface MultiSelectionHost : LifecycleOwner { val multiSelectionBinding: MultiSelectionBinding @@ -33,8 +34,16 @@ interface MultiSelectionHost : LifecycleOwner { fun directionToThreadActionsBottomSheetDialog( threadUid: String, shouldLoadDistantResources: Boolean, - shouldCloseMultiSelection: Boolean + shouldCloseMultiSelection: Boolean, + isFromSearch: Boolean, ): NavDirections - fun directionsToMultiSelectBottomSheetDialog(): NavDirections + fun directionsToFolderPickerFragment( + threadsUids: Array, + messagesUids: Array? = null, + action: FolderPickerAction = FolderPickerAction.MOVE, + sourceFolderId: String? = null + ): NavDirections + + fun directionsToMultiSelectBottomSheetDialog(isFromSearch: Boolean): NavDirections } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt index d479a617d5..4435390aee 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt @@ -25,6 +25,8 @@ import com.infomaniak.mail.MatomoMail.trackMultiSelectionEvent import com.infomaniak.mail.data.models.thread.Thread import dagger.hilt.android.lifecycle.HiltViewModel import io.realm.kotlin.notifications.ResultsChange +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import javax.inject.Inject @HiltViewModel @@ -42,6 +44,9 @@ class MultiselectionViewModel @Inject constructor( inline val selectedThreads get() = selectedThreadsLiveData.value!! + inline val selectedMessages + get() = selectedThreads.flatMap { thread -> thread.messages } + fun isEverythingSelected(currentThreadCount: Int): Boolean { return selectedThreads.count() == currentThreadCount } @@ -59,6 +64,19 @@ class MultiselectionViewModel @Inject constructor( publishSelectedItems() } + suspend fun selectOrUnselectAllSearchItems(searchResults: Flow>) { + val isEverythingSelected = isEverythingSelected(searchResults.first().list.count()) + if (isEverythingSelected) { + trackMultiSelectionEvent(MatomoName.None) + selectedThreads.clear() + } else { + trackMultiSelectionEvent(MatomoName.All) + searchResults.first().list.forEach { thread -> selectedThreads.add(thread) } + } + + publishSelectedItems() + } + fun publishSelectedItems() { selectedThreadsLiveData.value = selectedThreads } diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index 495489fed6..a02c2b9d99 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -143,7 +143,6 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:paddingBottom="@dimen/recyclerViewPaddingBottom" - app:behind_swiped_item_icon_margin="@dimen/marginStandard" app:layout_behavior="@string/appbar_scrolling_view_behavior"> + + Date: Fri, 22 May 2026 13:41:02 +0200 Subject: [PATCH 10/52] feat: Add swipe, fix share email action and refresh move --- .../main/folder/ThreadListMultiSelection.kt | 65 +------------------ .../main/folderPicker/FolderPickerFragment.kt | 7 +- .../mail/ui/main/search/SearchFragment.kt | 15 ++++- .../mail/ui/main/thread/ThreadFragment.kt | 2 + .../actions/MailActionsBottomSheetDialog.kt | 6 +- .../MessageActionsBottomSheetDialog.kt | 11 ++++ .../actions/MultiSelectBottomSheetDialog.kt | 5 +- .../actions/ThreadActionsBottomSheetDialog.kt | 34 +++++++--- .../thread/actions/ThreadActionsViewModel.kt | 3 +- .../main/res/navigation/main_navigation.xml | 8 +++ 10 files changed, 74 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index e0dead2cec..036f5236fb 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -82,73 +82,12 @@ class ThreadListMultiSelection { if (isFromSearch && searchViewModel != null) { this.searchViewModel = searchViewModel - setupMessageMultiSelectionActions() - } else { - setupMultiSelectionActions() } + setupMultiSelectionActions() observerMultiSelection() } - private fun setupMessageMultiSelectionActions() = with(multiselectionViewModel) { - host.multiSelectionBinding.quickActionBar.setOnItemClickListener { menuId -> - val selectedThreadsCount = selectedThreads.count() - val selectedThreadsUids = selectedThreads.map { it.uid } - val selectedMessagesUids = selectedMessages.map { it.uid } - val selectedMessagesCount = selectedMessagesUids.count() - val mailbox = mainViewModel.currentMailbox.value ?: return@setOnItemClickListener - val currentFolderId = mainViewModel.currentFolderId - val currentFolder = mainViewModel.currentFolder.value - - when (menuId) { - R.id.quickActionUnread -> { - trackMultiSelectActionEvent(MatomoName.MarkAsSeen, selectedMessagesCount) - actionsViewModel.toggleMessagesSeenStatus(selectedMessages, shouldMultiselectRead, currentFolderId, mailbox) - val hasSeenFilter = searchViewModel.currentFilters.contains(Thread.ThreadFilter.UNSEEN) - || searchViewModel.currentFilters.contains(Thread.ThreadFilter.SEEN) - if (hasSeenFilter) searchViewModel.refreshSearch() - isMultiSelectOn = false - } - R.id.quickActionArchive -> host.lifecycleScope.launch { - trackMultiSelectActionEvent(MatomoName.Archive, selectedMessagesCount) - actionsViewModel.archiveMessages(selectedMessages, currentFolder, mailbox) - searchViewModel.refreshSearch() - isMultiSelectOn = false - } - R.id.quickActionFavorite -> { - trackMultiSelectActionEvent(MatomoName.Favorite, selectedThreadsCount) - actionsViewModel.toggleMessagesFavoriteStatus(selectedMessages, shouldMultiselectFavorite, mailbox) - if (searchViewModel.currentFilters.contains(Thread.ThreadFilter.STARRED)) searchViewModel.refreshSearch() - isMultiSelectOn = false - } - R.id.quickActionDelete -> host.lifecycleScope.launch { - host.descriptionDialog.deleteWithConfirmationPopup( - folderRoles = host.folderRoleUtils.getActionFolderRoles(selectedMessages), - count = selectedThreads.count(), - ) { - trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) - actionsViewModel.deleteMessages(selectedMessages, currentFolder, mailbox) - isMultiSelectOn = false - } - } - R.id.quickActionMenu -> { - trackMultiSelectActionEvent(MatomoName.OpenBottomSheet, selectedThreadsCount) - val direction = if (selectedThreadsCount == 1) { - host.directionToThreadActionsBottomSheetDialog( - threadUid = selectedThreadsUids.single(), - shouldLoadDistantResources = false, - shouldCloseMultiSelection = false, - isFromSearch = isFromSearch - ) - } else { - host.directionsToMultiSelectBottomSheetDialog(isFromSearch) - } - host.safeNavigation(direction) - } - } - } - } - private fun setupMultiSelectionActions() = with(multiselectionViewModel) { host.multiSelectionBinding.quickActionBar.setOnItemClickListener { menuId -> val selectedThreadsUids = selectedThreads.map { it.uid } @@ -207,7 +146,7 @@ class ThreadListMultiSelection { host.directionToThreadActionsBottomSheetDialog( threadUid = selectedThreadsUids.single(), shouldLoadDistantResources = false, - shouldCloseMultiSelection = false, + shouldCloseMultiSelection = true, isFromSearch = isFromSearch, ) } else { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt index def1b5e7d6..26e3a3ee97 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt @@ -43,6 +43,7 @@ import com.infomaniak.mail.ui.alertDialogs.CreateFolderDialog import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.search.SearchViewModel import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel +import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.utils.Utils import com.infomaniak.mail.utils.extensions.applySideAndBottomSystemInsets import com.infomaniak.mail.utils.extensions.applyStatusBarInsets @@ -65,6 +66,7 @@ class FolderPickerFragment : Fragment() { private val searchViewModel: SearchViewModel by activityViewModels() private val mainViewModel: MainViewModel by activityViewModels() private val actionsViewModel: ActionsViewModel by activityViewModels() + private val multiselectionViewModel: MultiselectionViewModel by activityViewModels() @Inject lateinit var createFolderDialog: CreateFolderDialog @@ -173,7 +175,6 @@ class FolderPickerFragment : Fragment() { snackbarManager.postValue(getString(RCore.string.anErrorHasOccurred)) return@with } - if (messagesUids != null) { actionsViewModel.moveMessagesTo( destinationFolderId = folderId, @@ -189,6 +190,10 @@ class FolderPickerFragment : Fragment() { mailbox = mailbox, ) } + if (navigationArgs.isFromSearch) { + multiselectionViewModel.isMultiSelectOn = false + searchViewModel.refreshSearch() + } } private fun setupSearchBar() = with(binding) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 4eb3f9e8d5..e2451d7f37 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -33,6 +33,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import androidx.lifecycle.asFlow import androidx.lifecycle.lifecycleScope import androidx.navigation.NavDirections import androidx.navigation.fragment.findNavController @@ -92,6 +93,7 @@ import com.infomaniak.mail.utils.extensions.setOnClearTextClickListener import dagger.hilt.android.AndroidEntryPoint import io.realm.kotlin.types.RealmInstant import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject import com.infomaniak.core.legacy.R as RCore @@ -385,6 +387,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { } multiselectToolbar.cancel.setOnClickListener { trackMultiSelectionEvent(MatomoName.Cancel) + searchViewModel.refreshSearch(withContacts = true) multiselectionViewModel.isMultiSelectOn = false } multiselectToolbar.selectAll.setOnClickListener { @@ -466,6 +469,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { threadUid = threadUid, shouldLoadDistantResources = false, shouldCloseMultiSelection = false, + isFromSearch = true, ) } @@ -650,7 +654,14 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { private fun observeSearchResults() = viewLifecycleOwner.lifecycleScope.launch { searchViewModel.allSearchResults.collectLatest { searchResults -> - threadListAdapter.updateListWithThreadListItems(searchResults, viewLifecycleOwner.lifecycleScope) + // Wait for any running swipe animation to finish before updating the list + if (threadListViewModel.isRecoveringFinished.value == false) { + threadListViewModel.isRecoveringFinished.asFlow().first { it } + } + + binding.mailRecyclerView.postOnAnimation { + threadListAdapter.updateListWithThreadListItems(searchResults, viewLifecycleOwner.lifecycleScope) + } } } @@ -666,8 +677,6 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { multiselectionViewModel.isMultiSelectOnLiveData.observe(viewLifecycleOwner) { isMultiSelectOn -> if (isMultiSelectOn) { searchViewModel.contactsResults.value = emptyList() - } else { - searchViewModel.refreshSearch(withContacts = true) } val autoTransition = AutoTransition() autoTransition.duration = TOOLBAR_FADE_DURATION diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt index 43d9542bc6..8f16a76ad7 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt @@ -92,6 +92,7 @@ import com.infomaniak.mail.ui.main.emojiPicker.PickerEmojiObserver import com.infomaniak.mail.ui.main.folder.ThreadListItem import com.infomaniak.mail.ui.main.folder.TwoPaneFragment import com.infomaniak.mail.ui.main.folder.TwoPaneViewModel +import com.infomaniak.mail.ui.main.search.SearchFragment import com.infomaniak.mail.ui.main.thread.SubjectFormatter.SubjectData import com.infomaniak.mail.ui.main.thread.ThreadAdapter.ContextMenuType import com.infomaniak.mail.ui.main.thread.ThreadAdapter.DisplayType @@ -1027,6 +1028,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { threadUid = twoPaneViewModel.currentThreadUid.value ?: return, isThemeTheSame = threadViewModel.threadState.isThemeTheSameMap[uid] ?: return, shouldLoadDistantResources = shouldLoadDistantResources(uid), + isFromSearch = parentFragment is SearchFragment ).toBundle(), ) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt index 5d2e4fca59..2b2935758c 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt @@ -30,7 +30,6 @@ import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.R import com.infomaniak.mail.databinding.BottomSheetActionsMenuBinding import com.infomaniak.mail.ui.MainViewModel -import com.infomaniak.mail.ui.main.folder.ThreadListFragment import com.infomaniak.mail.ui.main.folder.TwoPaneViewModel import com.infomaniak.mail.ui.main.thread.actions.ActionItemView.TrailingContent import com.infomaniak.mail.utils.AccountUtils @@ -45,6 +44,9 @@ abstract class MailActionsBottomSheetDialog : ActionsBottomSheetDialog() { protected val twoPaneViewModel: TwoPaneViewModel by activityViewModels() abstract val shouldCloseMultiSelection: Boolean + // Add this abstract property + protected abstract val substituteClassName: String + private var onClickListener: OnActionClick = object : OnActionClick { @@ -100,7 +102,7 @@ abstract class MailActionsBottomSheetDialog : ActionsBottomSheetDialog() { print.setClosingOnClickListener(shouldCloseMultiSelection) { onClickListener.onPrint() } share.setClosingOnClickListener(shouldCloseMultiSelection) { if (mainViewModel.currentMailbox.value?.kSuite is KSuite.Perso.Free) { - openMyKSuiteUpgradeBottomSheet(MatomoName.ShareEmail.value, ThreadListFragment::class.java.name) + openMyKSuiteUpgradeBottomSheet(MatomoName.ShareEmail.value, substituteClassName) } else { onClickListener.onShare() } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt index 7a7cb6f843..21708bedf4 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt @@ -39,8 +39,10 @@ import com.infomaniak.mail.data.models.message.Message import com.infomaniak.mail.ui.MainActivity import com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog import com.infomaniak.mail.ui.main.SnackbarManager +import com.infomaniak.mail.ui.main.folder.ThreadListFragment import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.folderPicker.FolderPickerFragmentArgs +import com.infomaniak.mail.ui.main.search.SearchFragment import com.infomaniak.mail.ui.main.thread.PrintMailFragmentArgs import com.infomaniak.mail.ui.main.thread.ThreadFragment.Companion.OPEN_REACTION_BOTTOM_SHEET import com.infomaniak.mail.ui.main.thread.actions.ThreadActionsBottomSheetDialog.Companion.setBlockUserUi @@ -72,6 +74,15 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { private val currentClassName: String by lazy { MessageActionsBottomSheetDialog::class.java.name } override val shouldCloseMultiSelection: Boolean = false + + override val substituteClassName: String by lazy { + if (navigationArgs.isFromSearch) { + SearchFragment::class.java.name + } else { + ThreadListFragment::class.java.name + } + } + private var isFromSpam: Boolean = false @Inject diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index 8c54f14282..b9492402dc 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -310,7 +310,8 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { threadsUids = threadsUids.toTypedArray(), messagesUids = multiselectionViewModel.selectedMessages.getUids().toTypedArray(), action = FolderPickerAction.MOVE, - sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID, + sourceFolderId = null, + isFromSearch = true, ) ) } else { @@ -352,7 +353,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { setTitle(favoriteText) } - setSnoozeUi(threads) + if (!navigationArgs.isFromSearch) setSnoozeUi(threads) ThreadActionsBottomSheetDialog.setSpamUi( spam = binding.spam, isFromSpam = mainViewModel.currentFolder.value?.role == FolderRole.SPAM diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt index 1bb99388c7..19c3ca70a4 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt @@ -48,6 +48,8 @@ import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.folder.ThreadListFragment import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.folderPicker.FolderPickerFragmentArgs +import com.infomaniak.mail.ui.main.search.SearchFragment +import com.infomaniak.mail.ui.main.search.SearchViewModel import com.infomaniak.mail.ui.main.thread.ThreadFragment.Companion.OPEN_REACTION_BOTTOM_SHEET import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel @@ -74,6 +76,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { private val threadActionsViewModel: ThreadActionsViewModel by viewModels() private val actionsViewModel: ActionsViewModel by activityViewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() + private val searchViewModel: SearchViewModel by activityViewModels() private val currentClassName: String by lazy { ThreadActionsBottomSheetDialog::class.java.name } override val shouldCloseMultiSelection by lazy { navigationArgs.shouldCloseMultiSelection } @@ -113,8 +116,10 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { setMarkAsReadUi(thread.isSeen) setArchiveUi(isFromArchive) setFavoriteUi(thread.isFavorite) - setSnoozeUi(thread.isSnoozed()) - setReactionUi(canBeReactedTo = messageUidToReactTo != null) + if (!navigationArgs.isFromSearch) { + setSnoozeUi(thread.isSnoozed()) + setReactionUi(canBeReactedTo = messageUidToReactTo != null) + } setSpamUi(binding.spam, isFromSpam) initOnClickListener(onActionClick(thread, messageUidToExecuteAction, messageUidToReactTo)) @@ -124,6 +129,14 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { observeReportPhishingResult() } + override val substituteClassName: String by lazy { + if (navigationArgs.isFromSearch) { + SearchFragment::class.java.name + } else { + ThreadListFragment::class.java.name + } + } + private fun setSnoozeUi(isThreadSnoozed: Boolean) = with(binding) { val shouldDisplaySnoozeActions = SharedUtils.shouldDisplaySnoozeActions(mainViewModel, localSettings, folderRole) @@ -214,7 +227,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! ) - multiselectionViewModel.isMultiSelectOn = false + if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) } } @@ -225,7 +238,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!!, ) - multiselectionViewModel.isMultiSelectOn = false + if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) twoPaneViewModel.closeThread() } @@ -238,10 +251,11 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { args = FolderPickerFragmentArgs( threadsUids = arrayOf(navigationArgs.threadUid), action = FolderPickerAction.MOVE, - sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID + sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID, + isFromSearch = navigationArgs.isFromSearch ).toBundle(), ) - multiselectionViewModel.isMultiSelectOn = false + if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) } } @@ -272,7 +286,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { threadsUids = listOf(navigationArgs.threadUid), mailbox = mainViewModel.currentMailbox.value!!, ) - multiselectionViewModel.isMultiSelectOn = false + if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) } override fun onSpam() { @@ -282,7 +296,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!!, ) - multiselectionViewModel.isMultiSelectOn = false + if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) } override fun onPhishing() { @@ -307,7 +321,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { }, ) - multiselectionViewModel.isMultiSelectOn = false + if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) } override fun onBlockSender() { @@ -329,7 +343,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { junkMessagesViewModel.messageOfUserToBlock.value = message } } - multiselectionViewModel.isMultiSelectOn = false + if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) } override fun onPrint() { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsViewModel.kt index f1e419e36a..c1671c8c24 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsViewModel.kt @@ -52,7 +52,8 @@ class ThreadActionsViewModel @Inject constructor( private val ioCoroutineContext = viewModelScope.coroutineContext(ioDispatcher) - private val threadUid inline get() = savedStateHandle.get(ThreadActionsBottomSheetDialogArgs::threadUid.name)!! + private val threadUid: String = savedStateHandle.get(ThreadActionsBottomSheetDialogArgs::threadUid.name) + ?: throw IllegalArgumentException("threadUid is required") private val threadMessageToExecuteAction: Flow = threadController.getThreadAsync(threadUid) .mapNotNull { it.obj?.let { thread -> getThreadAndMessageUidToExecuteAction(thread) } } diff --git a/app/src/main/res/navigation/main_navigation.xml b/app/src/main/res/navigation/main_navigation.xml index 382d563317..9cfd2b5d97 100644 --- a/app/src/main/res/navigation/main_navigation.xml +++ b/app/src/main/res/navigation/main_navigation.xml @@ -198,6 +198,10 @@ + + Date: Wed, 27 May 2026 10:16:20 +0200 Subject: [PATCH 11/52] fix: FolderRoles for deleting dialog confirmation --- .../mail/ui/main/folder/PerformSwipeActionManager.kt | 3 ++- .../infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index a32e85d55c..a651532d95 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -138,7 +138,8 @@ object PerformSwipeActionManager { val folderRoles = thread.messages.mapNotNull { message -> if (message.isSnoozed()) FolderRole.SNOOZED else message.folder.role } host.descriptionDialog.deleteWithConfirmationPopup( - folderRoles = folderRoles, + messagesFolderRoles = folderRoles, + currentFolderRole = thread.folder.role, count = 1, displayLoader = false, onCancel = { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index 036f5236fb..ff71199bd3 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -132,7 +132,7 @@ class ThreadListMultiSelection { val allMessages = selectedThreads.flatMap { it.messages } host.descriptionDialog.deleteWithConfirmationPopup( messagesFolderRoles = host.folderRoleUtils.getActionFolderRoles(allMessages), - currentFolderRole = currentFolder.value?.role, + currentFolderRole = mainViewModel.currentFolder.value?.role, count = selectedThreadsCount, ) { trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) From a86d0b3582942e84cd4ced64985c103f02392ecc Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Wed, 27 May 2026 10:49:11 +0200 Subject: [PATCH 12/52] fix: Add isFromSearch in SearchFragment directions --- .../infomaniak/mail/ui/main/search/SearchFragment.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index e2451d7f37..f1e7ea8b31 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -157,10 +157,11 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { sourceFolderId: String? ): NavDirections { return SearchFragmentDirections.actionSearchFragmentToFolderPickerFragment( - threadsUids, - messagesUids, - FolderPickerAction.MOVE, - sourceFolderId + threadsUids = threadsUids, + messagesUids = messagesUids, + action = FolderPickerAction.MOVE, + sourceFolderId = sourceFolderId, + isFromSearch = true, ) } @@ -461,6 +462,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { threadsUids = arrayOf(threadUid), action = FolderPickerAction.MOVE, sourceFolderId = sourceFolderId, + isFromSearch = true, ) } From 9c29bb75583e1cc0f8a3f05af19e04c8e69552c4 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Wed, 27 May 2026 10:49:38 +0200 Subject: [PATCH 13/52] refactor: Add argument names and use action argument instead of action move --- .../infomaniak/mail/ui/main/folder/ThreadListFragment.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 8fca1c5890..5dc5d1c4e0 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 @@ -400,9 +400,10 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio sourceFolderId: String? ): NavDirections { return ThreadListFragmentDirections.actionThreadListFragmentToFolderPickerFragment( - threadsUids, - messagesUids, - FolderPickerAction.MOVE, sourceFolderId + threadsUids = threadsUids, + messagesUids = messagesUids, + action = action, + sourceFolderId = sourceFolderId, ) } From 1d055cbb2e5f42331cc85da0553a968c257ccff0 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Wed, 27 May 2026 12:04:22 +0200 Subject: [PATCH 14/52] refactor: Don't call searchResults first twice --- .../thread/actions/multiselection/MultiselectionViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt index 4435390aee..540a3901be 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt @@ -65,13 +65,14 @@ class MultiselectionViewModel @Inject constructor( } suspend fun selectOrUnselectAllSearchItems(searchResults: Flow>) { - val isEverythingSelected = isEverythingSelected(searchResults.first().list.count()) + val results = searchResults.first().list + val isEverythingSelected = isEverythingSelected(results.count()) if (isEverythingSelected) { trackMultiSelectionEvent(MatomoName.None) selectedThreads.clear() } else { trackMultiSelectionEvent(MatomoName.All) - searchResults.first().list.forEach { thread -> selectedThreads.add(thread) } + results.forEach { thread -> selectedThreads.add(thread) } } publishSelectedItems() From 1d602b9fc82b70fb0571568520cdf0ee09c6e9c8 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Wed, 27 May 2026 12:17:05 +0200 Subject: [PATCH 15/52] fix: Select current folder as default in search --- .../mail/ui/main/search/SearchFragment.kt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index f1e7ea8b31..32fac2eb1e 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -506,18 +506,12 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { } private fun selectCurrentFolder() { - val sourceFolder = mainViewModel.currentFolder.value - if (!searchViewModel.isAllFoldersSelected && searchViewModel.filterFolder == null && sourceFolder?.role != FolderRole.INBOX) { - searchViewModel.selectFolder(sourceFolder) + val currentFolder = mainViewModel.currentFolder.value + if (!searchViewModel.isAllFoldersSelected && searchViewModel.filterFolder == null && currentFolder?.role != FolderRole.INBOX) { + searchViewModel.selectFolder(currentFolder) } - updateAllFoldersButtonUi() - if (searchViewModel.filterFolder == null) { - val currentFolder = mainViewModel.currentFolder.value - if (currentFolder?.role != FolderRole.INBOX) { - searchViewModel.selectFolder(currentFolder) - } - } + updateAllFoldersButtonUi() trackSearchEvent(ThreadFilter.FOLDER.matomoName, true) } From 99f3400c956883ceaf49889b220d4a1e8a9424ff Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Wed, 27 May 2026 13:01:01 +0200 Subject: [PATCH 16/52] fix: Don't use host as lifecycleowner --- .../com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt | 4 ++++ .../mail/ui/main/folder/ThreadListMultiSelection.kt | 4 ++-- .../java/com/infomaniak/mail/ui/main/search/SearchFragment.kt | 4 ++++ .../main/thread/actions/multiselection/MultiselectionHost.kt | 1 + 4 files changed, 11 insertions(+), 2 deletions(-) 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 5dc5d1c4e0..80919e9525 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 @@ -39,6 +39,7 @@ import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle.State +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavDirections @@ -164,6 +165,9 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio safelyNavigate(directions) } + override val multiSelectionLifecycleOwner: LifecycleOwner + get() = viewLifecycleOwner + override fun disableSwipeDirection(direction: DirectionFlag) { binding.threadsList.disableSwipeDirection(direction) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index ff71199bd3..47a1bcb40e 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -159,7 +159,7 @@ class ThreadListMultiSelection { } private fun observerMultiSelection() = with(host) { - multiselectionViewModel.isMultiSelectOnLiveData.observe(host) { isMultiSelectOn -> + multiselectionViewModel.isMultiSelectOnLiveData.observe(multiSelectionLifecycleOwner) { isMultiSelectOn -> threadListAdapter.updateSelection() if (localSettings.threadDensity != ThreadDensity.LARGE) TransitionManager.beginDelayedTransition(host.multiSelectionBinding.threadsList) if (!isMultiSelectOn) multiselectionViewModel.selectedThreads.clear() @@ -170,7 +170,7 @@ class ThreadListMultiSelection { displayMultiSelectActions(isMultiSelectOn) } - multiselectionViewModel.selectedThreadsLiveData.observe(host) { selectedThreads -> + multiselectionViewModel.selectedThreadsLiveData.observe(multiSelectionLifecycleOwner) { selectedThreads -> if (selectedThreads.isEmpty()) { multiselectionViewModel.isMultiSelectOn = false } else { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 32fac2eb1e..5e6af290e4 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -33,6 +33,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.asFlow import androidx.lifecycle.lifecycleScope import androidx.navigation.NavDirections @@ -116,6 +117,9 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { safelyNavigate(directions) } + override val multiSelectionLifecycleOwner: LifecycleOwner + get() = viewLifecycleOwner + override fun disableSwipeDirection(direction: DirectionFlag) { binding.mailRecyclerView.disableSwipeDirection(direction) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt index 2de24de46d..64669c82aa 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt @@ -24,6 +24,7 @@ import com.infomaniak.mail.ui.main.folder.ThreadListAdapter import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction interface MultiSelectionHost : LifecycleOwner { + val multiSelectionLifecycleOwner: LifecycleOwner val multiSelectionBinding: MultiSelectionBinding val folderRoleUtils: com.infomaniak.mail.utils.FolderRoleUtils val descriptionDialog: com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog From 037de6b7fee8a8fc9cc23344dd4efa05811c6d77 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Wed, 27 May 2026 13:01:22 +0200 Subject: [PATCH 17/52] fix: update select all label --- .../mail/ui/main/folder/ThreadListMultiSelection.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index 47a1bcb40e..952dd41825 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -222,10 +222,12 @@ class ThreadListMultiSelection { } private fun updateSelectAllLabel() { - val currentThreadsSelected = mainViewModel.currentThreadsLive.value?.list?.count() ?: 0 - val isEverythingSelected = multiselectionViewModel.isEverythingSelected(currentThreadsSelected) - val selectAllLabel = - if (isEverythingSelected) R.string.buttonUnselectAll else R.string.buttonSelectAll + val currentThreadCount = host.threadListAdapter.dataSet + .filterIsInstance() + .count() + + val isEverythingSelected = multiselectionViewModel.isEverythingSelected(currentThreadCount) + val selectAllLabel = if (isEverythingSelected) R.string.buttonUnselectAll else R.string.buttonSelectAll host.multiSelectionBinding.multiselectToolbar.selectAll.setText(selectAllLabel) } From 57e29913bf64d88b967a3495954da3d154bfb9e0 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Wed, 27 May 2026 16:37:44 +0200 Subject: [PATCH 18/52] fix: Fix copilot recommendations --- .../mail/ui/main/folder/PerformSwipeActionManager.kt | 2 +- .../mail/ui/main/folderPicker/FolderPickerFragment.kt | 6 +++--- .../main/thread/actions/MailActionsBottomSheetDialog.kt | 1 - .../main/thread/actions/MultiSelectBottomSheetDialog.kt | 9 ++++++--- .../thread/actions/ThreadActionsBottomSheetDialog.kt | 1 - .../java/com/infomaniak/mail/useCases/MessagesActions.kt | 4 +--- 6 files changed, 11 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index a651532d95..8f68e40897 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -286,7 +286,7 @@ object PerformSwipeActionManager { directions = ThreadListFragmentDirections.actionThreadListFragmentToFolderPickerFragment( threadsUids = arrayOf(thread.uid), action = FolderPickerAction.MOVE, - sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID + sourceFolderId = thread.folderId ), ) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt index 26e3a3ee97..80d6422e8f 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt @@ -179,20 +179,20 @@ class FolderPickerFragment : Fragment() { actionsViewModel.moveMessagesTo( destinationFolderId = folderId, messagesUids = messagesUids.toList(), - currentFolderId = mainViewModel.currentFolderId, + currentFolderId = navigationArgs.sourceFolderId, mailbox = mailbox, ) } else { actionsViewModel.moveThreadsTo( destinationFolderId = folderId, threadsUids = threadsUids.toList(), - currentFolderId = mainViewModel.currentFolderId, + currentFolderId = navigationArgs.sourceFolderId, mailbox = mailbox, ) } if (navigationArgs.isFromSearch) { multiselectionViewModel.isMultiSelectOn = false - searchViewModel.refreshSearch() + searchViewModel.refreshSearch(withContacts = true) } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt index 2b2935758c..8057003cba 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt @@ -44,7 +44,6 @@ abstract class MailActionsBottomSheetDialog : ActionsBottomSheetDialog() { protected val twoPaneViewModel: TwoPaneViewModel by activityViewModels() abstract val shouldCloseMultiSelection: Boolean - // Add this abstract property protected abstract val substituteClassName: String diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index b9492402dc..8936efeaa6 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -303,14 +303,17 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { } private fun navigateToFolderPickerFragment(threadsUids: List) { + val messagesUids = multiselectionViewModel.selectedMessages.getUids().toTypedArray() + multiselectionViewModel.isMultiSelectOn = false val navController = findNavController() + if (navigationArgs.isFromSearch) { navController.animatedNavigation( directions = SearchFragmentDirections.actionSearchFragmentToFolderPickerFragment( threadsUids = threadsUids.toTypedArray(), - messagesUids = multiselectionViewModel.selectedMessages.getUids().toTypedArray(), + messagesUids = messagesUids, action = FolderPickerAction.MOVE, - sourceFolderId = null, + sourceFolderId = searchViewModel.filterFolder?.id, isFromSearch = true, ) ) @@ -318,7 +321,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { navController.animatedNavigation( directions = ThreadListFragmentDirections.actionThreadListFragmentToFolderPickerFragment( threadsUids = threadsUids.toTypedArray(), - messagesUids = multiselectionViewModel.selectedMessages.getUids().toTypedArray(), + messagesUids = messagesUids, action = FolderPickerAction.MOVE, sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID, ), diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt index 19c3ca70a4..23149332e9 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt @@ -255,7 +255,6 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { isFromSearch = navigationArgs.isFromSearch ).toBundle(), ) - if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) } } diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt index 80d1e74d3d..b0da1ee3bc 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt @@ -88,9 +88,7 @@ class MessagesActions @Inject constructor( } suspend fun getMessagesFromThreadsToMove(threads: List): List { - return threads.flatMap { - messageController.getMovableMessages(it) - } + return threads.flatMap { messageController.getMovableMessages(it) } } fun getMessagesToMove(messages: List, currentFolderId: String?): List { From 2870433ea63b1b8f2c901c0a0e9370dce6b6e4d2 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 28 May 2026 10:49:23 +0200 Subject: [PATCH 19/52] fix: Fix rebase --- .../mail/ui/main/folder/ThreadListMultiSelection.kt | 6 +++++- .../java/com/infomaniak/mail/useCases/MessagesActions.kt | 4 ---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index 952dd41825..94d5baeb17 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -136,7 +136,11 @@ class ThreadListMultiSelection { count = selectedThreadsCount, ) { trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) - actionsViewModel.deleteThreads(selectedThreads.toList(), currentFolder.value, currentMailBox) + actionsViewModel.deleteThreads( + selectedThreads.toList(), + mainViewModel.currentFolder.value, + currentMailBox + ) isMultiSelectOn = false } } diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt index b0da1ee3bc..ea685df60a 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt @@ -95,10 +95,6 @@ class MessagesActions @Inject constructor( return messages.filter { message -> message.folderId == currentFolderId && !message.isScheduledMessage } } - fun getMessagesToMove(messages: List, currentFolderId: String?): List { - return messages.filter { message -> message.folderId == currentFolderId && !message.isScheduledMessage } - } - private suspend fun moveMessages( mailbox: Mailbox, messagesToMove: List, From 644e880d7972b58e4f773f5a14561940ab947edf Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 28 May 2026 13:36:14 +0200 Subject: [PATCH 20/52] fix: Refresh after actions in SearchFragment and make thread folder the source folder for actions --- .../main/folder/PerformSwipeActionManager.kt | 15 +++++----- .../main/folderPicker/FolderPickerFragment.kt | 1 - .../mail/ui/main/search/SearchFragment.kt | 13 +++++++++ .../mail/ui/main/thread/ThreadFragment.kt | 8 ++--- .../main/thread/actions/ActionsViewModel.kt | 29 ++++++++++++++++++- .../actions/ThreadActionsBottomSheetDialog.kt | 13 ++------- 6 files changed, 55 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index 8f68e40897..c4da428bfe 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -27,7 +27,6 @@ import com.infomaniak.mail.MatomoMail.MatomoCategory import com.infomaniak.mail.MatomoMail.trackEvent import com.infomaniak.mail.R import com.infomaniak.mail.data.LocalSettings -import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.SwipeAction import com.infomaniak.mail.data.models.isSnoozed @@ -128,7 +127,7 @@ object PerformSwipeActionManager { ) { host.actionsViewModel.archiveThreads( listOf(thread), - host.mainViewModel.currentFolder.value, + thread.folder, host.mainViewModel.currentMailbox.value!! ) } @@ -151,7 +150,7 @@ object PerformSwipeActionManager { if (isPermanentDeleteFolder) host.threadListAdapter.removeItem(position) host.actionsViewModel.deleteThreads( listOf(thread), - host.mainViewModel.currentFolder.value, + thread.folder, host.mainViewModel.currentMailbox.value!! ) }, @@ -173,7 +172,7 @@ object PerformSwipeActionManager { navController.animatedNavigation( directions = host.directionsToMove( threadUid = thread.uid, - sourceFolderId = host.mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID, + sourceFolderId = thread.folderId, ) ) } @@ -189,7 +188,7 @@ object PerformSwipeActionManager { host.actionsViewModel.toggleThreadsSeenStatus( threadsUids = listOf(thread.uid), shouldRead = !thread.isSeen, - currentFolderId = host.mainViewModel.currentFolderId, + currentFolderId = thread.folderId, mailbox = host.mainViewModel.currentMailbox.value!! ) host.mainViewModel.currentFilter.value != ThreadFilter.UNSEEN @@ -198,7 +197,7 @@ object PerformSwipeActionManager { SwipeAction.SPAM -> { host.actionsViewModel.toggleThreadsSpamStatus( threads = setOf(thread), - currentFolderId = host.mainViewModel.currentFolderId, + currentFolderId = thread.folderId, mailbox = host.mainViewModel.currentMailbox.value!! ) false @@ -348,7 +347,7 @@ object PerformSwipeActionManager { fun onSuccess() { actionsViewModel.archiveThreads( threads = listOf(thread), - currentFolder = mainViewModel.currentFolder.value, + currentFolder = thread.folder, mailbox = currentMailBox ) } @@ -380,7 +379,7 @@ object PerformSwipeActionManager { if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) actionsViewModel.deleteThreads( threads = listOf(thread), - currentFolder = mainViewModel.currentFolder.value, + currentFolder = thread.folder, mailbox = currentMailBox ) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt index 80d6422e8f..4db6f6c5d3 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt @@ -192,7 +192,6 @@ class FolderPickerFragment : Fragment() { } if (navigationArgs.isFromSearch) { multiselectionViewModel.isMultiSelectOn = false - searchViewModel.refreshSearch(withContacts = true) } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 5e6af290e4..9751468b2a 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -33,9 +33,11 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.asFlow import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavDirections import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager @@ -250,6 +252,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { observeSearchResults() observeHistory() observeMultiSelect() + observeSearchRefresh() } private fun handleEdgeToEdge(): Unit = with(binding) { @@ -303,6 +306,16 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { super.handleOnBackPressed() } + private fun observeSearchRefresh() { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + actionsViewModel.searchRefreshEvents.collect { + searchViewModel.refreshSearch(withContacts = true) + } + } + } + } + private fun updateSwipeActionsAccordingToSettings() { unlockSwipeActionsIfSet() diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt index 8f16a76ad7..08f2ff8e71 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt @@ -930,22 +930,22 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { val thread = threadViewModel.threadLive.value ?: return@archiveWithConfirmationPopup actionsViewModel.archiveThreads( threads = listOf(thread), - currentFolder = mainViewModel.currentFolder.value, + currentFolder = thread.folder, mailbox = mainViewModel.currentMailbox.value!!, ) } } R.id.quickActionDelete -> { + val thread = threadViewModel.threadLive.value ?: return@setOnItemClickListener descriptionDialog.deleteWithConfirmationPopup( messagesFolderRoles, - currentFolderRole = mainViewModel.currentFolder.value?.role, + currentFolderRole = thread.folder.role, count = 1 ) { trackThreadActionsEvent(MatomoName.Delete) - val thread = threadViewModel.threadLive.value ?: return@deleteWithConfirmationPopup actionsViewModel.deleteThreads( threads = listOf(thread), - currentFolder = mainViewModel.currentFolder.value, + currentFolder = thread.folder, mailbox = mainViewModel.currentMailbox.value!!, ) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index e722444bb8..257a7831b7 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -54,6 +54,8 @@ import com.infomaniak.mail.utils.extensions.getUids import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import java.util.Date import javax.inject.Inject @@ -89,6 +91,15 @@ class ActionsViewModel @Inject constructor( val activityDialogLoaderResetTrigger = SingleLiveEvent() val reportPhishingTrigger = SingleLiveEvent() + //region refreshSearch + private val _searchRefreshEvents = Channel(Channel.BUFFERED) + val searchRefreshEvents = _searchRefreshEvents.receiveAsFlow() + + private suspend fun notifySearchRefresh() { + _searchRefreshEvents.send(Unit) + } + //endregion + //region AutoAdvance fun updateCurrentThreadPosition(currentPosition: Int, currentUid: String) { currentThread = currentPosition to currentUid @@ -148,6 +159,7 @@ class ActionsViewModel @Inject constructor( currentFolderId = currentFolderId, threadsUids = movedThreads, ) + notifySearchRefresh() } if (displaySnackbar) showMoveSnackbar( @@ -250,6 +262,8 @@ class ActionsViewModel @Inject constructor( threadController.updateIsLocallyMovedOutStatus(movedThreads, hasBeenMovedOut = false) } } + + notifySearchRefresh() } showMoveSnackbar( @@ -380,6 +394,7 @@ class ActionsViewModel @Inject constructor( currentFolderId = currentFolder.id, threadsUids = uidsToMove, ) + notifySearchRefresh() val numberOfImpactedThreads = uidsToMove.distinct().count() showDeleteSnackbar( apiResponses = apiResponses, @@ -468,6 +483,7 @@ class ActionsViewModel @Inject constructor( messagesFoldersIds = result.messages.getFoldersIds(), currentFolderId = currentFolderId, ) + notifySearchRefresh() } } @@ -484,9 +500,9 @@ class ActionsViewModel @Inject constructor( messagesFoldersIds = messages.getFoldersIds(), currentFolderId = currentFolderId, ) + notifySearchRefresh() } } - //endregion //region Favorite @@ -501,6 +517,7 @@ class ActionsViewModel @Inject constructor( mailbox = mailbox, messagesFoldersIds = result.messages.getFoldersIds(), ) + notifySearchRefresh() } } @@ -515,6 +532,7 @@ class ActionsViewModel @Inject constructor( mailbox = mailbox, messagesFoldersIds = messages.getFoldersIds(), ) + notifySearchRefresh() } } //endregion @@ -542,6 +560,7 @@ class ActionsViewModel @Inject constructor( when (result) { is MessagesActions.ApiCallResult.Success -> { reportPhishingTrigger.postValue(Unit) + notifySearchRefresh() snackbarManager.postValue(appContext.getString(result.messageRes)) } is MessagesActions.ApiCallResult.Error -> { @@ -580,6 +599,7 @@ class ActionsViewModel @Inject constructor( if (result is MessagesActions.SnoozeResult.Success) { refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(FolderRole.SNOOZED))) + notifySearchRefresh() } val message = when (result) { @@ -603,6 +623,7 @@ class ActionsViewModel @Inject constructor( if (result is BatchSnoozeResult.Success) { refreshFoldersAsync(mailbox, result.impactedFolders) + notifySearchRefresh() } val message = when (result) { @@ -628,6 +649,7 @@ class ActionsViewModel @Inject constructor( if (result is BatchSnoozeResult.Success) { refreshFoldersAsync(mailbox, result.impactedFolders) + notifySearchRefresh() } val message = when (result) { @@ -665,6 +687,7 @@ class ActionsViewModel @Inject constructor( with(messagesActions.rescheduleDraft(resource, scheduleDate)) { if (isSuccess()) { refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(FolderRole.SCHEDULED_DRAFTS))) + notifySearchRefresh() } else { snackbarManager.postValue(title = appContext.getString(translateError())) } @@ -687,6 +710,7 @@ class ActionsViewModel @Inject constructor( } refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(draftFolder.id))) + notifySearchRefresh() onSuccess() } else { snackbarManager.postValue(title = appContext.getString(apiResponse.translateError())) @@ -703,6 +727,7 @@ class ActionsViewModel @Inject constructor( } refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(scheduledDraftsFolder.id))) + notifySearchRefresh() } showUnscheduledDraftSnackbar(apiResponse, openFolder) @@ -739,6 +764,7 @@ class ActionsViewModel @Inject constructor( } refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(draftFolder.id))) + notifySearchRefresh() } showDeletedDraftSnackbar(apiResponse) @@ -762,6 +788,7 @@ class ActionsViewModel @Inject constructor( messagesFoldersIds = foldersIds, destinationFolderId = destinationFolderId, ) + notifySearchRefresh() } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt index 23149332e9..196c257dce 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt @@ -35,7 +35,6 @@ import com.infomaniak.mail.MatomoMail.trackBottomSheetThreadActionsEvent import com.infomaniak.mail.R import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.cache.mailboxContent.ThreadController -import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.correspondent.Recipient import com.infomaniak.mail.data.models.draft.Draft.DraftMode @@ -227,7 +226,6 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! ) - if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) } } @@ -238,12 +236,12 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!!, ) - if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) twoPaneViewModel.closeThread() } override fun onMove() { val navController = findNavController() + val isFromSearch = navigationArgs.isFromSearch descriptionDialog.moveWithConfirmationPopup(folderRole, count = 1) { trackBottomSheetThreadActionsEvent(MatomoName.Move) navController.animatedNavigation( @@ -251,8 +249,8 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { args = FolderPickerFragmentArgs( threadsUids = arrayOf(navigationArgs.threadUid), action = FolderPickerAction.MOVE, - sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID, - isFromSearch = navigationArgs.isFromSearch + sourceFolderId = thread.folderId, + isFromSearch = isFromSearch ).toBundle(), ) } @@ -285,7 +283,6 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { threadsUids = listOf(navigationArgs.threadUid), mailbox = mainViewModel.currentMailbox.value!!, ) - if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) } override fun onSpam() { @@ -295,7 +292,6 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!!, ) - if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) } override fun onPhishing() { @@ -319,8 +315,6 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { ) }, ) - - if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) } override fun onBlockSender() { @@ -342,7 +336,6 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { junkMessagesViewModel.messageOfUserToBlock.value = message } } - if (navigationArgs.isFromSearch) searchViewModel.refreshSearch(withContacts = true) } override fun onPrint() { From 85bacd9cdc7359cd222db939bfbe5053dbd8fff9 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 28 May 2026 14:51:36 +0200 Subject: [PATCH 21/52] fix: Use folderId instead of folder for archive and delete since it's safer --- .../main/folder/PerformSwipeActionManager.kt | 8 ++--- .../main/folder/ThreadListMultiSelection.kt | 4 +-- .../mail/ui/main/thread/ThreadFragment.kt | 4 +-- .../main/thread/actions/ActionsViewModel.kt | 34 ++++++++++--------- .../MessageActionsBottomSheetDialog.kt | 4 +-- .../actions/MultiSelectBottomSheetDialog.kt | 4 +-- .../actions/ThreadActionsBottomSheetDialog.kt | 4 +-- 7 files changed, 32 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index c4da428bfe..67171ba2c1 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -127,7 +127,7 @@ object PerformSwipeActionManager { ) { host.actionsViewModel.archiveThreads( listOf(thread), - thread.folder, + thread.folderId, host.mainViewModel.currentMailbox.value!! ) } @@ -150,7 +150,7 @@ object PerformSwipeActionManager { if (isPermanentDeleteFolder) host.threadListAdapter.removeItem(position) host.actionsViewModel.deleteThreads( listOf(thread), - thread.folder, + thread.folderId, host.mainViewModel.currentMailbox.value!! ) }, @@ -347,7 +347,7 @@ object PerformSwipeActionManager { fun onSuccess() { actionsViewModel.archiveThreads( threads = listOf(thread), - currentFolder = thread.folder, + currentFolderId = thread.folderId, mailbox = currentMailBox ) } @@ -379,7 +379,7 @@ object PerformSwipeActionManager { if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) actionsViewModel.deleteThreads( threads = listOf(thread), - currentFolder = thread.folder, + currentFolderId = thread.folderId, mailbox = currentMailBox ) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index 94d5baeb17..6d19d9557b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -113,7 +113,7 @@ class ThreadListMultiSelection { trackMultiSelectActionEvent(MatomoName.Archive, selectedThreadsCount) actionsViewModel.archiveThreads( threads = selectedThreads.toList(), - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = mainViewModel.currentFolderId, mailbox = currentMailBox, ) isMultiSelectOn = false @@ -138,7 +138,7 @@ class ThreadListMultiSelection { trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) actionsViewModel.deleteThreads( selectedThreads.toList(), - mainViewModel.currentFolder.value, + mainViewModel.currentFolderId, currentMailBox ) isMultiSelectOn = false diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt index 08f2ff8e71..83ab27397e 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt @@ -930,7 +930,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { val thread = threadViewModel.threadLive.value ?: return@archiveWithConfirmationPopup actionsViewModel.archiveThreads( threads = listOf(thread), - currentFolder = thread.folder, + currentFolderId = thread.folderId, mailbox = mainViewModel.currentMailbox.value!!, ) } @@ -945,7 +945,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { trackThreadActionsEvent(MatomoName.Delete) actionsViewModel.deleteThreads( threads = listOf(thread), - currentFolder = thread.folder, + currentFolderId = thread.folderId, mailbox = mainViewModel.currentMailbox.value!!, ) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index 257a7831b7..72bcc3e2d0 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -248,6 +248,7 @@ class ActionsViewModel @Inject constructor( currentThread?.let { (_, uid) -> if (movedThreads.isNotEmpty() && movedThreads.contains(uid)) tryToAutoAdvance.postValue(Unit) } + refreshFoldersAsync( mailbox = mailbox, messagesFoldersIds = messages.getFoldersIds(exception = destinationFolder.id), @@ -309,28 +310,28 @@ class ActionsViewModel @Inject constructor( //region Delete fun deleteThreads( threads: List, - currentFolder: Folder?, + currentFolderId: String?, mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { val messagesToDelete = messagesActions.getMessagesFromThreadsToDelete(threads) - handleDeleteMessages(messagesToDelete, currentFolder, mailbox) + handleDeleteMessages(messagesToDelete, currentFolderId, mailbox) } fun deleteMessages( messages: List, - currentFolder: Folder?, + currentFolderId: String?, mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { val messagesToDelete = messagesActions.getMessagesToDelete(messages) - handleDeleteMessages(messagesToDelete, currentFolder, mailbox) + handleDeleteMessages(messagesToDelete, currentFolderId, mailbox) } private suspend fun handleDeleteMessages( messagesToDelete: List, - currentFolder: Folder?, + currentFolderId: String?, mailbox: Mailbox, ) { - if (currentFolder == null) return + if (currentFolderId == null) return val permanentlyDeleteMessages = messagesToDelete.filter { message -> isPermanentDeleteFolder(role = folderRoleUtils.getActionFolderRole(message)) @@ -343,7 +344,7 @@ class ActionsViewModel @Inject constructor( // If deleteMessages is empty we will do the auto advance after deleting permanently if (onlyPermanentlyDeleteMessages) calculateCurrentThreadPosition.postValue(Unit) handlePermanentlyDeleteMessages( - permanentlyDeleteMessages, mailbox, currentFolder, onlyPermanentlyDeleteMessages, messagesToDelete + permanentlyDeleteMessages, mailbox, currentFolderId, onlyPermanentlyDeleteMessages, messagesToDelete ) } @@ -358,7 +359,7 @@ class ActionsViewModel @Inject constructor( moveMessagesTo( destinationFolderId = destinationFolder.id, messagesUids = deleteMessages.getUids(), - currentFolderId = currentFolder.id, + currentFolderId = currentFolderId, mailbox = mailbox, ) } @@ -367,10 +368,11 @@ class ActionsViewModel @Inject constructor( private suspend fun handlePermanentlyDeleteMessages( permanentlyDeleteMessages: List, mailbox: Mailbox, - currentFolder: Folder, + currentFolderId: String, shouldAutoAdvanceAndRefresh: Boolean, messagesToDelete: List ) { + val currentFolder = folderController.getFolder(currentFolderId) ?: return val result = messagesActions.permanentlyDelete( messagesToDelete = permanentlyDeleteMessages, mailbox = mailbox, @@ -438,25 +440,25 @@ class ActionsViewModel @Inject constructor( //region Archive fun archiveThreads( threads: List, - currentFolder: Folder?, + currentFolderId: String?, mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { val messagesToMove = messagesActions.getMessagesFromThreadsToMove(threads) - handleArchiveMessages(messagesToMove, currentFolder, mailbox) + handleArchiveMessages(messagesToMove, currentFolderId, mailbox) } fun archiveMessages( messages: List, - currentFolder: Folder?, + currentFolderId: String?, mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - val messagesToMove = messagesActions.getMessagesToMove(messages, currentFolder?.id) - handleArchiveMessages(messagesToMove, currentFolder, mailbox) + val messagesToMove = messagesActions.getMessagesToMove(messages, currentFolderId) + handleArchiveMessages(messagesToMove, currentFolderId, mailbox) } private suspend fun handleArchiveMessages( messages: List, - currentFolder: Folder?, + currentFolderId: String?, mailbox: Mailbox, ) { if (messages.isEmpty()) return @@ -465,7 +467,7 @@ class ActionsViewModel @Inject constructor( val destinationFolderRole = if (isFromArchive) FolderRole.INBOX else FolderRole.ARCHIVE val destinationFolder = folderController.getFolder(destinationFolderRole) ?: return - moveMessagesTo(destinationFolder.id, messages.getUids(), currentFolder?.id, mailbox) + moveMessagesTo(destinationFolder.id, messages.getUids(), currentFolderId, mailbox) } //region Seen diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt index 21708bedf4..1435b1c263 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt @@ -195,7 +195,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetMessageActionsEvent(MatomoName.Delete) actionsViewModel.deleteMessages( messages = listOf(message), - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!!, ) } @@ -208,7 +208,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetMessageActionsEvent(MatomoName.Archive, message.folder.role == FolderRole.ARCHIVE) actionsViewModel.archiveMessages( messages = listOf(message), - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!!, ) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index 8936efeaa6..46394bf3ab 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -263,7 +263,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { trackMultiSelectActionEvent(MatomoName.Archive, threadsCount, isFromBottomSheet = true) actionsViewModel.archiveThreads( threads = threads.toList(), - currentFolder = currentFolder, + currentFolderId = currentFolderId, mailbox = currentMailbox, ) multiselectionViewModel.isMultiSelectOn = false @@ -278,7 +278,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { trackMultiSelectActionEvent(MatomoName.Delete, threadsCount, isFromBottomSheet = true) actionsViewModel.deleteThreads( threads = threads.toList(), - currentFolder = currentFolder, + currentFolderId = currentFolderId, mailbox = currentMailbox, ) multiselectionViewModel.isMultiSelectOn = false diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt index 196c257dce..c2f483008b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt @@ -210,7 +210,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetThreadActionsEvent(MatomoName.Delete) actionsViewModel.deleteThreads( threads = listOf(thread), - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!! ) } @@ -223,7 +223,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetThreadActionsEvent(MatomoName.Archive, isFromArchive) actionsViewModel.archiveThreads( threads = listOf(thread), - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!! ) } From 182c5c975012996b694f507772a64bbc58d9cad1 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 28 May 2026 15:52:56 +0200 Subject: [PATCH 22/52] refactor: Make variable private --- .../java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index b934cf4aed..f3bed7615a 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -95,7 +95,7 @@ class SearchViewModel @Inject constructor( private var currentUiState: SearchUiState = SearchUiState.IDLE - var currentFilters = mutableSetOf() + private var currentFilters = mutableSetOf() var isAllFoldersSelected: Boolean = false private var lastExecutedFolder: Folder? = null From 7a6338fa39f41664408a72755aa47d7cb8fd30e3 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 28 May 2026 15:56:50 +0200 Subject: [PATCH 23/52] fix: Don't use current folder since from search the source folder can be different from each thread --- .../main/thread/actions/ActionsViewModel.kt | 12 ++----- .../mail/useCases/MessagesActions.kt | 32 ++++++++++--------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index 72bcc3e2d0..f2bad9eaa3 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -213,8 +213,7 @@ class ActionsViewModel @Inject constructor( mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { val messages = messageController.getMessages(messagesUids) - val messagesToMove = messagesActions.getMessagesToMove(messages, currentFolderId) - handleMessagesMove(destinationFolderId, messagesToMove, currentFolderId, mailbox) + handleMessagesMove(destinationFolderId, messages, currentFolderId, mailbox) } private suspend fun handleMessagesMove( @@ -224,12 +223,7 @@ class ActionsViewModel @Inject constructor( mailbox: Mailbox, ) { val destinationFolder = folderController.getFolder(destinationFolderId) - if (currentFolderId == null || destinationFolder == null) { - snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) - return - } - - val currentFolder = folderController.getFolder(currentFolderId) ?: run { + if (destinationFolder == null) { snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) return } @@ -237,7 +231,6 @@ class ActionsViewModel @Inject constructor( calculateCurrentThreadPosition.postValue(Unit) val result = messagesActions.moveMessagesTo( - currentFolder = currentFolder, destinationFolder = destinationFolder, mailbox = mailbox, messages = messages, @@ -377,7 +370,6 @@ class ActionsViewModel @Inject constructor( messagesToDelete = permanentlyDeleteMessages, mailbox = mailbox, onApiFinished = { activityDialogLoaderResetTrigger.postValue(Unit) }, - currentFolder = currentFolder, ) ?: run { snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) return diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt index ea685df60a..101d1f3d4a 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt @@ -63,13 +63,12 @@ class MessagesActions @Inject constructor( // Move Region suspend fun moveMessagesTo( - currentFolder: Folder, destinationFolder: Folder, mailbox: Mailbox, messages: List, ): MoveMessagesResult { - val movedThreads = moveOutMessagesThreadsLocally(messages, currentFolder) + val movedThreads = moveOutMessagesThreadsLocally(messages) val featureFlags = mailbox.featureFlags val apiResponses = moveMessages( @@ -115,15 +114,24 @@ class MessagesActions @Inject constructor( return apiResponses } - private suspend fun moveOutMessagesThreadsLocally(messages: List, currentFolder: Folder): List { + private suspend fun moveOutMessagesThreadsLocally(messages: List): List { val threadsUidsToMove = mutableListOf() val movingMessageUids = messages.getUids().toSet() + val movingFolderIds = messages.mapTo(mutableSetOf(), Message::folderId) + mailboxContentRealm().run { messages.flatMapTo(mutableSetOf(), Message::threads).forEach { thread -> val realmThread = ThreadController.getThreadBlocking(thread.uid, realm = this) ?: return@forEach + val messagesInThreadNotMoving = realmThread.messages.filterNot { it.uid in movingMessageUids } - val messagesInCurrentFolder = messagesInThreadNotMoving.count { it.folderId == currentFolder.id } - if (messagesInCurrentFolder == 0) threadsUidsToMove.add(realmThread.uid) + + val stillHasMessagesInMovedFolders = messagesInThreadNotMoving.any { message -> + message.folderId in movingFolderIds + } + + if (!stillHasMessagesInMovedFolders) { + threadsUidsToMove.add(realmThread.uid) + } } } @@ -142,9 +150,7 @@ class MessagesActions @Inject constructor( ): MoveMessagesResult? { if (currentFolderId == null) return null - val currentFolder = folderController.getFolder(currentFolderId) ?: return null val destinationFolder = folderController.getFolder(destinationFolderId) ?: return null - var messagesToMove: List if (messagesUids != null) { messagesToMove = messagesUids.let { messageController.getMessages(it) } @@ -154,7 +160,7 @@ class MessagesActions @Inject constructor( messagesToMove = getMessagesFromThreadsToMove(threads) } - return moveMessagesTo(currentFolder, destinationFolder, mailbox, messagesToMove) + return moveMessagesTo(destinationFolder, mailbox, messagesToMove) } // End Region @@ -165,16 +171,13 @@ class MessagesActions @Inject constructor( mailbox: Mailbox, ): MoveMessagesResult? { if (currentFolderId == null) return null - val folder = folderController.getFolder(currentFolderId) ?: return null - val messagesFolderRoles = folderRoleUtils.getActionFolderRoles(messages) + val messagesFolderRoles = folderRoleUtils.getActionFolderRoles(messages) val destinationFolderRole = if (messagesFolderRoles.contains(FolderRole.SPAM)) FolderRole.INBOX else FolderRole.SPAM - val destinationFolder = folderController.getFolder(destinationFolderRole) ?: return null val unscheduleMessages = messageController.getUnscheduledMessages(messages) - - return moveMessagesTo(folder, destinationFolder, mailbox, unscheduleMessages) + return moveMessagesTo(destinationFolder, mailbox, unscheduleMessages) } suspend fun getMessagesFromThreadsToSpamOrHam(threads: Set): List { @@ -195,14 +198,13 @@ class MessagesActions @Inject constructor( messagesToDelete: List, mailbox: Mailbox, onApiFinished: () -> Unit, - currentFolder: Folder ): DeleteResult? { if (messagesToDelete.isEmpty()) { return null } val uids = messagesToDelete.getUids() - val uidsToMove = moveOutMessagesThreadsLocally(messagesToDelete, currentFolder) + val uidsToMove = moveOutMessagesThreadsLocally(messagesToDelete) val apiResponses = ApiRepository.deleteMessages( mailboxUuid = mailbox.uuid, From 9be43e9c2bd09c73561f73052b3815fcdab7d96a Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 28 May 2026 15:57:14 +0200 Subject: [PATCH 24/52] refactor: Remove unused viewmodel --- .../ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt index c2f483008b..7cafd49b40 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt @@ -48,7 +48,6 @@ import com.infomaniak.mail.ui.main.folder.ThreadListFragment import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.folderPicker.FolderPickerFragmentArgs import com.infomaniak.mail.ui.main.search.SearchFragment -import com.infomaniak.mail.ui.main.search.SearchViewModel import com.infomaniak.mail.ui.main.thread.ThreadFragment.Companion.OPEN_REACTION_BOTTOM_SHEET import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel @@ -75,7 +74,6 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { private val threadActionsViewModel: ThreadActionsViewModel by viewModels() private val actionsViewModel: ActionsViewModel by activityViewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() - private val searchViewModel: SearchViewModel by activityViewModels() private val currentClassName: String by lazy { ThreadActionsBottomSheetDialog::class.java.name } override val shouldCloseMultiSelection by lazy { navigationArgs.shouldCloseMultiSelection } From 25d68d15bac0cbf71ec6a15afee666dafb135c51 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 28 May 2026 15:57:36 +0200 Subject: [PATCH 25/52] fix: Make isFromArchive work with multiselect dans la search --- .../ui/main/thread/actions/MultiSelectBottomSheetDialog.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index 46394bf3ab..6f88ddbc8d 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -122,16 +122,17 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { junkMessagesViewModel.threadsUids = threadsUids val (shouldRead, shouldFavorite) = ThreadListMultiSelection.computeReadFavoriteStatus(threads) - val isFromArchive = mainViewModel.currentFolder.value?.role == FolderRole.ARCHIVE + lifecycleScope.launch { val folderRole = folderRoleUtils.getThreadsActionFolderRole(threads) val messages = threads.flatMap { it.messages } val messagesFolderRoles = folderRoleUtils.getActionFolderRoles(messages) + val isFromArchive = mainViewModel.currentFolder.value?.role == FolderRole.ARCHIVE || + messagesFolderRoles.all { it == FolderRole.ARCHIVE } setupMainActions(threads, threadsUids, shouldRead, folderRole, messagesFolderRoles) + setStateDependentUi(shouldRead, shouldFavorite, isFromArchive, threads) } - setStateDependentUi(shouldRead, shouldFavorite, isFromArchive, threads) - observeReportPhishingResult() observePotentialBlockedSenders() From 89210383b7585900eea617e15901877931ae0542 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 28 May 2026 16:40:18 +0200 Subject: [PATCH 26/52] refactor: Use same swipe functions for search and threadlist --- .../main/folder/PerformSwipeActionManager.kt | 244 ++++-------------- .../mail/ui/main/folder/ThreadListFragment.kt | 50 +++- .../mail/ui/main/search/SearchFragment.kt | 9 + 3 files changed, 107 insertions(+), 196 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index 67171ba2c1..e61ad9d0c8 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -22,7 +22,6 @@ import androidx.navigation.NavDirections import androidx.navigation.fragment.findNavController import com.infomaniak.core.fragmentnavigation.safelyNavigate import com.infomaniak.core.matomo.Matomo.TrackerAction -import com.infomaniak.core.sentry.SentryLog import com.infomaniak.mail.MatomoMail.MatomoCategory import com.infomaniak.mail.MatomoMail.trackEvent import com.infomaniak.mail.R @@ -35,7 +34,6 @@ import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog -import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.settings.appearance.swipe.SwipeActionsSettingsFragment import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel @@ -45,7 +43,6 @@ import com.infomaniak.mail.utils.extensions.deleteWithConfirmationPopup import com.infomaniak.mail.utils.extensions.getAnimatedNavOptions import com.infomaniak.mail.utils.extensions.moveWithConfirmationPopup import io.realm.kotlin.types.RealmInstant -import com.infomaniak.core.common.R as RCore object PerformSwipeActionManager { @@ -66,8 +63,6 @@ object PerformSwipeActionManager { } /** - * Generic API usable by SearchFragment (and others). - * * The boolean return value is used to know if we should keep the Thread in * the RecyclerView (true), or remove it when the swipe is done (false). */ @@ -77,6 +72,7 @@ object PerformSwipeActionManager { thread: Thread, position: Int, isPermanentDeleteFolder: Boolean, + currentMailbox: Mailbox, ): Boolean { val folderRole = thread.folder.role if (!swipeAction.canDisplay(folderRole, host.mainViewModel.featureFlagsLive.value, host.localSettings)) { @@ -93,6 +89,7 @@ object PerformSwipeActionManager { thread = thread, position = position, isPermanentDeleteFolder = isPermanentDeleteFolder, + currentMailbox = currentMailbox ) val shouldKeepItemBecauseOfNoConnection = !host.mainViewModel.hasNetwork @@ -106,231 +103,86 @@ object PerformSwipeActionManager { thread: Thread, position: Int, isPermanentDeleteFolder: Boolean, - ) = when (swipeAction) { - SwipeAction.TUTORIAL -> { - host.localSettings.setDefaultSwipeActions() - host.fragment.findNavController() - .navigate(R.id.swipeActionsSettingsFragment, args = null, getAnimatedNavOptions()) - true - } - - SwipeAction.ARCHIVE -> { - host.descriptionDialog.archiveWithConfirmationPopup( - folderRole = folderRole, - count = 1, - displayLoader = false, - onCancel = { - if (host.threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { - host.threadListAdapter.notifyItemChanged(position) - } - }, - ) { - host.actionsViewModel.archiveThreads( - listOf(thread), - thread.folderId, - host.mainViewModel.currentMailbox.value!! - ) - } - } - - SwipeAction.DELETE -> { - val folderRoles = - thread.messages.mapNotNull { message -> if (message.isSnoozed()) FolderRole.SNOOZED else message.folder.role } - host.descriptionDialog.deleteWithConfirmationPopup( - messagesFolderRoles = folderRoles, - currentFolderRole = thread.folder.role, - count = 1, - displayLoader = false, - onCancel = { - if (host.threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { - host.threadListAdapter.notifyItemChanged(position) - } - }, - callback = { - if (isPermanentDeleteFolder) host.threadListAdapter.removeItem(position) - host.actionsViewModel.deleteThreads( - listOf(thread), - thread.folderId, - host.mainViewModel.currentMailbox.value!! - ) - }, - ) - } - - SwipeAction.FAVORITE -> { - host.actionsViewModel.toggleThreadsFavoriteStatus( - threadsUids = listOf(thread.uid), - shouldFavorite = !thread.isFavorite, - mailbox = host.mainViewModel.currentMailbox.value!! - ) - true - } - - SwipeAction.MOVE -> { - val navController = host.fragment.findNavController() - host.descriptionDialog.moveWithConfirmationPopup(folderRole, count = 1) { - navController.animatedNavigation( - directions = host.directionsToMove( - threadUid = thread.uid, - sourceFolderId = thread.folderId, - ) - ) - } - true - } - - SwipeAction.QUICKACTIONS_MENU -> { - host.fragment.safelyNavigate(host.directionsToQuickActions(thread.uid)) - true - } - - SwipeAction.READ_UNREAD -> { - host.actionsViewModel.toggleThreadsSeenStatus( - threadsUids = listOf(thread.uid), - shouldRead = !thread.isSeen, - currentFolderId = thread.folderId, - mailbox = host.mainViewModel.currentMailbox.value!! - ) - host.mainViewModel.currentFilter.value != ThreadFilter.UNSEEN - } - - SwipeAction.SPAM -> { - host.actionsViewModel.toggleThreadsSpamStatus( - threads = setOf(thread), - currentFolderId = thread.folderId, - mailbox = host.mainViewModel.currentMailbox.value!! - ) - false - } - - SwipeAction.SNOOZE -> { - val snoozeScheduleType = if (thread.isSnoozed()) { - SnoozeScheduleType.Modify(thread.uid) - } else { - SnoozeScheduleType.Snooze(thread.uid) - } - host.navigateToSnoozeBottomSheet(snoozeScheduleType, thread.snoozeEndDate) - true - } - - SwipeAction.NONE -> error("Cannot swipe on an action which is not set") - } - - /** - * The boolean return value is used to know if we should keep the Thread in - * the RecyclerView (true), or remove it when the swipe is done (false). - */ - fun ThreadListFragment.performSwipeAction( - swipeAction: SwipeAction, - thread: Thread, - position: Int, - isPermanentDeleteFolder: Boolean, + currentMailbox: Mailbox, ): Boolean { - val folderRole = thread.folder.role - if (!swipeAction.canDisplay(folderRole, mainViewModel.featureFlagsLive.value, localSettings)) { - snackbarManager.setValue(getString(R.string.snackbarSwipeActionIncompatible)) - return true - } - - trackEvent(MatomoCategory.SwipeActions, swipeAction.matomoName, TrackerAction.DRAG) - - val shouldKeepItemBecauseOfAction = performSwipeAction( - swipeAction = swipeAction, - folderRole = folderRole, - thread = thread, - position = position, - isPermanentDeleteFolder = isPermanentDeleteFolder, - ) - - val shouldKeepItemBecauseOfNoConnection = !mainViewModel.hasNetwork - return shouldKeepItemBecauseOfAction || shouldKeepItemBecauseOfNoConnection - } - - private fun ThreadListFragment.performSwipeAction( - swipeAction: SwipeAction, - folderRole: FolderRole?, - thread: Thread, - position: Int, - isPermanentDeleteFolder: Boolean - ): Boolean { - val currentMailbox = mainViewModel.currentMailbox.value ?: run { - snackbarManager.setValue(getString(RCore.string.anErrorHasOccurred)) - SentryLog.e("PerformSwipeActionManager", getString(R.string.sentryErrorMailboxIsNull)) { scope -> - scope.setTag("context", "PerformSwipeActionManager.performSwipeAction") - } - return true - } - return when (swipeAction) { SwipeAction.TUTORIAL -> { - localSettings.setDefaultSwipeActions() - safelyNavigate(ThreadListFragmentDirections.actionThreadListFragmentToSettingsFragment()) - findNavController().navigate(R.id.swipeActionsSettingsFragment, args = null, getAnimatedNavOptions()) + host.localSettings.setDefaultSwipeActions() + host.fragment.findNavController() + .navigate(R.id.swipeActionsSettingsFragment, args = null, getAnimatedNavOptions()) true } + SwipeAction.ARCHIVE -> { - handleArchiveSwipe(thread, position, folderRole, currentMailbox) + handleArchiveSwipe(host, thread, position, folderRole, currentMailbox) } + SwipeAction.DELETE -> { - handleDeleteSwipe(thread, position, isPermanentDeleteFolder, currentMailbox) + handleDeleteSwipe(host, thread, position, isPermanentDeleteFolder, currentMailbox) } + SwipeAction.FAVORITE -> { - actionsViewModel.toggleThreadsFavoriteStatus(threadsUids = listOf(thread.uid), mailbox = currentMailbox) + host.actionsViewModel.toggleThreadsFavoriteStatus( + threadsUids = listOf(thread.uid), + shouldFavorite = !thread.isFavorite, + mailbox = currentMailbox + ) true } + SwipeAction.MOVE -> { - val navController = findNavController() - descriptionDialog.moveWithConfirmationPopup(folderRole, count = 1) { + val navController = host.fragment.findNavController() + host.descriptionDialog.moveWithConfirmationPopup(folderRole, count = 1) { navController.animatedNavigation( - directions = ThreadListFragmentDirections.actionThreadListFragmentToFolderPickerFragment( - threadsUids = arrayOf(thread.uid), - action = FolderPickerAction.MOVE, - sourceFolderId = thread.folderId - ), + directions = host.directionsToMove( + threadUid = thread.uid, + sourceFolderId = thread.folderId, + ) ) } true } + SwipeAction.QUICKACTIONS_MENU -> { - safelyNavigate( - ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( - threadUid = thread.uid, - shouldLoadDistantResources = false, - ) - ) + host.fragment.safelyNavigate(host.directionsToQuickActions(thread.uid)) true } + SwipeAction.READ_UNREAD -> { - actionsViewModel.toggleThreadsSeenStatus( + host.actionsViewModel.toggleThreadsSeenStatus( threadsUids = listOf(thread.uid), - currentFolderId = mainViewModel.currentFolderId, + shouldRead = !thread.isSeen, + currentFolderId = thread.folderId, mailbox = currentMailbox ) - mainViewModel.currentFilter.value != ThreadFilter.UNSEEN + host.mainViewModel.currentFilter.value != ThreadFilter.UNSEEN } SwipeAction.SPAM -> { - actionsViewModel.toggleThreadsSpamStatus( + host.actionsViewModel.toggleThreadsSpamStatus( threads = setOf(thread), - currentFolderId = mainViewModel.currentFolderId, + currentFolderId = thread.folderId, mailbox = currentMailbox ) false } + SwipeAction.SNOOZE -> { val snoozeScheduleType = if (thread.isSnoozed()) { SnoozeScheduleType.Modify(thread.uid) } else { SnoozeScheduleType.Snooze(thread.uid) } - navigateToSnoozeBottomSheet(snoozeScheduleType, thread.snoozeEndDate) + host.navigateToSnoozeBottomSheet(snoozeScheduleType, thread.snoozeEndDate) true } + SwipeAction.NONE -> error("Cannot swipe on an action which is not set") } } - private fun ThreadListFragment.handleArchiveSwipe( + private fun handleArchiveSwipe( + host: SwipeActionHost, thread: Thread, position: Int, folderRole: FolderRole?, @@ -339,20 +191,20 @@ object PerformSwipeActionManager { fun onCancel() { // Notify only if the user cancelled the popup (e.g. the thread is not deleted), // otherwise it will notify the next item in the list and make it slightly blink - if (threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { - threadListAdapter.notifyItemChanged(position) + if (host.threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { + host.threadListAdapter.notifyItemChanged(position) } } fun onSuccess() { - actionsViewModel.archiveThreads( + host.actionsViewModel.archiveThreads( threads = listOf(thread), currentFolderId = thread.folderId, mailbox = currentMailBox ) } - return descriptionDialog.archiveWithConfirmationPopup( + return host.descriptionDialog.archiveWithConfirmationPopup( folderRole = folderRole, count = 1, displayLoader = false, @@ -361,7 +213,8 @@ object PerformSwipeActionManager { ) } - private fun ThreadListFragment.handleDeleteSwipe( + private fun handleDeleteSwipe( + host: SwipeActionHost, thread: Thread, position: Int, isPermanentDeleteFolder: Boolean, @@ -370,14 +223,14 @@ object PerformSwipeActionManager { fun onCancel() { // Notify only if the user cancelled the popup (e.g. the thread is not deleted), // otherwise it will notify the next item in the list and make it slightly blink - if (threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { - threadListAdapter.notifyItemChanged(position) + if (host.threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { + host.threadListAdapter.notifyItemChanged(position) } } fun onHandleDelete() { - if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) - actionsViewModel.deleteThreads( + if (isPermanentDeleteFolder) host.threadListAdapter.removeItem(position) + host.actionsViewModel.deleteThreads( threads = listOf(thread), currentFolderId = thread.folderId, mailbox = currentMailBox @@ -386,7 +239,8 @@ object PerformSwipeActionManager { val folderRoles = thread.messages.mapNotNull { message -> if (message.isSnoozed()) FolderRole.SNOOZED else message.folder.role } - return descriptionDialog.deleteWithConfirmationPopup( + + return host.descriptionDialog.deleteWithConfirmationPopup( messagesFolderRoles = folderRoles, currentFolderRole = thread.folder.role, count = 1, 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 80919e9525..4a0036b483 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 @@ -35,6 +35,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePaddingRelative +import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -95,6 +96,7 @@ import com.infomaniak.mail.ui.main.emojiPicker.PickerEmojiObserver import com.infomaniak.mail.ui.main.folder.ThreadListViewModel.ContentDisplayMode import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.thread.ThreadFragment +import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType import com.infomaniak.mail.ui.main.thread.actions.EmojiReactionsViewModel import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionBinding import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionHost @@ -123,6 +125,7 @@ import com.infomaniak.mail.utils.extensions.safeNavigateToNewMessageActivity import com.infomaniak.mail.utils.extensions.shareString import com.infomaniak.mail.utils.extensions.toDate import dagger.hilt.android.AndroidEntryPoint +import io.realm.kotlin.types.RealmInstant import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.util.Date @@ -640,7 +643,52 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio position: Int, isPermanentDeleteFolder: Boolean, ): Boolean = with(PerformSwipeActionManager) { - performSwipeAction(swipeAction, thread, position, isPermanentDeleteFolder) + val currentMailbox = mainViewModel.currentMailbox.value ?: run { + snackbarManager.setValue(getString(com.infomaniak.core.common.R.string.anErrorHasOccurred)) + SentryLog.e("PerformSwipeActionManager", getString(R.string.sentryErrorMailboxIsNull)) { scope -> + scope.setTag("context", "PerformSwipeActionManager.performSwipeAction") + } + return true + } + val host = object : PerformSwipeActionManager.SwipeActionHost { + override val fragment: Fragment = this@ThreadListFragment + override val mainViewModel = this@ThreadListFragment.mainViewModel + override val actionsViewModel = this@ThreadListFragment.actionsViewModel + override val localSettings = this@ThreadListFragment.localSettings + override val threadListAdapter = this@ThreadListFragment.threadListAdapter + override val descriptionDialog = this@ThreadListFragment.descriptionDialog + + override fun showSwipeActionIncompatible() { + snackbarManager.setValue(getString(R.string.snackbarSwipeActionIncompatible)) + } + + override fun directionsToMove(threadUid: String, sourceFolderId: String): NavDirections { + return ThreadListFragmentDirections.actionThreadListFragmentToFolderPickerFragment( + threadsUids = arrayOf(threadUid), + action = FolderPickerAction.MOVE, + sourceFolderId = sourceFolderId, + isFromSearch = true, + ) + } + + override fun directionsToQuickActions(threadUid: String): NavDirections { + return ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( + threadUid = threadUid, + shouldLoadDistantResources = false, + shouldCloseMultiSelection = false, + isFromSearch = true, + ) + } + + override fun navigateToSnoozeBottomSheet( + snoozeScheduleType: SnoozeScheduleType?, + snoozeEndDate: RealmInstant?, + ) { + this@ThreadListFragment.navigateToSnoozeBottomSheet(snoozeScheduleType, snoozeEndDate) + } + } + + performSwipeAction(host, swipeAction, thread, position, isPermanentDeleteFolder, currentMailbox) } private fun extendCollapseFab(scrollDirection: ScrollDirection) = with(binding) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 9751468b2a..7111b40dd4 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -46,6 +46,7 @@ import com.infomaniak.core.fragmentnavigation.safelyNavigate import com.infomaniak.core.legacy.utils.Utils import com.infomaniak.core.legacy.utils.hideKeyboard import com.infomaniak.core.legacy.utils.showKeyboard +import com.infomaniak.core.sentry.SentryLog import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation.DirectionFlag import com.infomaniak.dragdropswiperecyclerview.listener.OnItemSwipeListener @@ -461,6 +462,13 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { position: Int, isPermanentDeleteFolder: Boolean, ): Boolean = with(PerformSwipeActionManager) { + val currentMailbox = mainViewModel.currentMailbox.value ?: run { + snackbarManager.setValue(getString(com.infomaniak.core.common.R.string.anErrorHasOccurred)) + SentryLog.e("PerformSwipeActionManager", getString(R.string.sentryErrorMailboxIsNull)) { scope -> + scope.setTag("context", "PerformSwipeActionManager.performSwipeAction") + } + return true + } val host = object : PerformSwipeActionManager.SwipeActionHost { override val fragment: Fragment = this@SearchFragment @@ -506,6 +514,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { thread = thread, position = position, isPermanentDeleteFolder = isPermanentDeleteFolder, + currentMailbox = currentMailbox ) } From 77c4460d788bb8002e1ac7869f73fdff0ce102e4 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 28 May 2026 16:55:24 +0200 Subject: [PATCH 27/52] fix: Use correct folderId --- .../mail/ui/main/folder/ThreadListMultiSelection.kt | 6 +++--- .../mail/ui/main/thread/actions/ActionsViewModel.kt | 4 ++-- .../actions/MessageActionsBottomSheetDialog.kt | 13 ++++++------- .../thread/actions/MultiSelectBottomSheetDialog.kt | 2 +- .../actions/ThreadActionsBottomSheetDialog.kt | 8 ++++---- 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index 6d19d9557b..c661233f7a 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -100,7 +100,7 @@ class ThreadListMultiSelection { actionsViewModel.toggleThreadsSeenStatus( threadsUids = selectedThreadsUids, shouldRead = shouldMultiselectRead, - currentFolderId = mainViewModel.currentFolderId, + currentFolderId = if (isFromSearch) searchViewModel.filterFolder?.id else mainViewModel.currentFolderId, mailbox = currentMailBox, ) isMultiSelectOn = false @@ -113,7 +113,7 @@ class ThreadListMultiSelection { trackMultiSelectActionEvent(MatomoName.Archive, selectedThreadsCount) actionsViewModel.archiveThreads( threads = selectedThreads.toList(), - currentFolderId = mainViewModel.currentFolderId, + currentFolderId = if (isFromSearch) searchViewModel.filterFolder?.id else mainViewModel.currentFolderId, mailbox = currentMailBox, ) isMultiSelectOn = false @@ -138,7 +138,7 @@ class ThreadListMultiSelection { trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) actionsViewModel.deleteThreads( selectedThreads.toList(), - mainViewModel.currentFolderId, + if (isFromSearch) searchViewModel.filterFolder?.id else mainViewModel.currentFolderId, currentMailBox ) isMultiSelectOn = false diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index f2bad9eaa3..d41d2b71c7 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -534,7 +534,7 @@ class ActionsViewModel @Inject constructor( //region Phishing fun reportPhishing( messages: List, - currentFolder: Folder?, + currentFolderId: String?, mailbox: Mailbox, ) { viewModelScope.launch(ioCoroutineContext) { @@ -544,7 +544,7 @@ class ActionsViewModel @Inject constructor( onReportSuccess = { toggleMessagesSpamStatus( messages = messages, - currentFolderId = currentFolder?.id, + currentFolderId = currentFolderId, mailbox = mailbox, displaySnackbar = false, ) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt index 1435b1c263..a37a9f6115 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt @@ -32,7 +32,6 @@ import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackBottomSheetMessageActionsEvent import com.infomaniak.mail.MatomoMail.trackBottomSheetThreadActionsEvent import com.infomaniak.mail.R -import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.draft.Draft.DraftMode import com.infomaniak.mail.data.models.message.Message @@ -195,7 +194,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetMessageActionsEvent(MatomoName.Delete) actionsViewModel.deleteMessages( messages = listOf(message), - currentFolderId = mainViewModel.currentFolderId, + currentFolderId = message.folderId, mailbox = mainViewModel.currentMailbox.value!!, ) } @@ -208,7 +207,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetMessageActionsEvent(MatomoName.Archive, message.folder.role == FolderRole.ARCHIVE) actionsViewModel.archiveMessages( messages = listOf(message), - currentFolderId = mainViewModel.currentFolderId, + currentFolderId = message.folderId, mailbox = mainViewModel.currentMailbox.value!!, ) } @@ -218,7 +217,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetMessageActionsEvent(MatomoName.MarkAsSeen, message.isSeen) actionsViewModel.toggleMessagesSeenStatus( messages = listOf(message), - currentFolderId = mainViewModel.currentFolderId, + currentFolderId = message.folderId, mailbox = mainViewModel.currentMailbox.value!!, ) twoPaneViewModel.closeThread() @@ -234,7 +233,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { action = FolderPickerAction.MOVE, threadsUids = arrayOf(threadUid), messagesUids = arrayOf(messageUid), - sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID, + sourceFolderId = message.folderId, ).toBundle(), ) } @@ -263,7 +262,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetMessageActionsEvent(MatomoName.Spam, value = isFromSpam) actionsViewModel.toggleMessagesSpamStatus( messages = listOf(message), - currentFolderId = mainViewModel.currentFolderId, + currentFolderId = message.folderId, mailbox = mainViewModel.currentMailbox.value!!, ) } @@ -276,7 +275,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { onPositiveButtonClicked = { actionsViewModel.reportPhishing( messages = listOf(message), - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = message.folderId, mailbox = mainViewModel.currentMailbox.value!!, ) }, diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index 6f88ddbc8d..fde289cc13 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -177,7 +177,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { onPositiveButtonClicked = { actionsViewModel.reportPhishing( messages = messages, - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = mainViewModel.currentFolderId, mailbox = currentMailbox, ) }, diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt index 7cafd49b40..c117125050 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt @@ -208,7 +208,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetThreadActionsEvent(MatomoName.Delete) actionsViewModel.deleteThreads( threads = listOf(thread), - currentFolderId = mainViewModel.currentFolderId, + currentFolderId = thread.folderId, mailbox = mainViewModel.currentMailbox.value!! ) } @@ -221,7 +221,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetThreadActionsEvent(MatomoName.Archive, isFromArchive) actionsViewModel.archiveThreads( threads = listOf(thread), - currentFolderId = mainViewModel.currentFolderId, + currentFolderId = thread.folderId, mailbox = mainViewModel.currentMailbox.value!! ) } @@ -231,7 +231,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetThreadActionsEvent(MatomoName.MarkAsSeen, value = thread.isSeen) actionsViewModel.toggleThreadsSeenStatus( threadsUids = listOf(navigationArgs.threadUid), - currentFolderId = mainViewModel.currentFolderId, + currentFolderId = thread.folderId, mailbox = mainViewModel.currentMailbox.value!!, ) twoPaneViewModel.closeThread() @@ -308,7 +308,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { onPositiveButtonClicked = { actionsViewModel.reportPhishing( messages = junkMessages, - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = thread.folderId, mailbox = mainViewModel.currentMailbox.value!!, ) }, From 28198aa2e753fc56720e57d42df32fad784c61b8 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Thu, 28 May 2026 17:15:15 +0200 Subject: [PATCH 28/52] fix: Fix copilot suggestions --- .../ui/main/folder/PerformSwipeActionManager.kt | 3 +-- .../mail/ui/main/folder/ThreadListFragment.kt | 4 ++-- .../mail/ui/main/search/SearchFragment.kt | 2 +- .../mail/ui/main/thread/actions/ActionsViewModel.kt | 13 +++++++------ app/src/main/res/layout/fragment_search.xml | 1 - 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index e61ad9d0c8..be4c0dcd0b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -31,7 +31,6 @@ import com.infomaniak.mail.data.models.SwipeAction import com.infomaniak.mail.data.models.isSnoozed import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.data.models.thread.Thread -import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog import com.infomaniak.mail.ui.main.settings.appearance.swipe.SwipeActionsSettingsFragment @@ -155,7 +154,7 @@ object PerformSwipeActionManager { currentFolderId = thread.folderId, mailbox = currentMailbox ) - host.mainViewModel.currentFilter.value != ThreadFilter.UNSEEN + true } SwipeAction.SPAM -> { 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 4a0036b483..5ae9f9fe7e 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 @@ -667,7 +667,7 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio threadsUids = arrayOf(threadUid), action = FolderPickerAction.MOVE, sourceFolderId = sourceFolderId, - isFromSearch = true, + isFromSearch = false, ) } @@ -676,7 +676,7 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio threadUid = threadUid, shouldLoadDistantResources = false, shouldCloseMultiSelection = false, - isFromSearch = true, + isFromSearch = false, ) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 7111b40dd4..7b6339d317 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -311,7 +311,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { actionsViewModel.searchRefreshEvents.collect { - searchViewModel.refreshSearch(withContacts = true) + searchViewModel.refreshSearch(withContacts = !multiselectionViewModel.isMultiSelectOn) } } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index d41d2b71c7..f025ca5143 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -324,8 +324,6 @@ class ActionsViewModel @Inject constructor( currentFolderId: String?, mailbox: Mailbox, ) { - if (currentFolderId == null) return - val permanentlyDeleteMessages = messagesToDelete.filter { message -> isPermanentDeleteFolder(role = folderRoleUtils.getActionFolderRole(message)) } @@ -337,7 +335,11 @@ class ActionsViewModel @Inject constructor( // If deleteMessages is empty we will do the auto advance after deleting permanently if (onlyPermanentlyDeleteMessages) calculateCurrentThreadPosition.postValue(Unit) handlePermanentlyDeleteMessages( - permanentlyDeleteMessages, mailbox, currentFolderId, onlyPermanentlyDeleteMessages, messagesToDelete + permanentlyDeleteMessages, + mailbox, + currentFolderId, + onlyPermanentlyDeleteMessages, + messagesToDelete ) } @@ -361,11 +363,10 @@ class ActionsViewModel @Inject constructor( private suspend fun handlePermanentlyDeleteMessages( permanentlyDeleteMessages: List, mailbox: Mailbox, - currentFolderId: String, + currentFolderId: String?, shouldAutoAdvanceAndRefresh: Boolean, messagesToDelete: List ) { - val currentFolder = folderController.getFolder(currentFolderId) ?: return val result = messagesActions.permanentlyDelete( messagesToDelete = permanentlyDeleteMessages, mailbox = mailbox, @@ -385,7 +386,7 @@ class ActionsViewModel @Inject constructor( refreshFoldersAsync( mailbox = mailbox, messagesFoldersIds = messagesToDelete.getFoldersIds(), - currentFolderId = currentFolder.id, + currentFolderId = currentFolderId, threadsUids = uidsToMove, ) notifySearchRefresh() diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index a02c2b9d99..5afef248bd 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -142,7 +142,6 @@ android:id="@+id/swipeRefreshLayout" android:layout_width="match_parent" android:layout_height="match_parent" - android:paddingBottom="@dimen/recyclerViewPaddingBottom" app:layout_behavior="@string/appbar_scrolling_view_behavior"> Date: Fri, 29 May 2026 10:12:04 +0200 Subject: [PATCH 29/52] refactor: Improve duplicated code --- .../main/folder/PerformSwipeActionManager.kt | 23 ------ .../mail/ui/main/folder/SwipeActionHost.kt | 43 +++++++++++ .../ui/main/folder/SwipeActionHostFactory.kt | 68 +++++++++++++++++ .../mail/ui/main/folder/ThreadListFragment.kt | 61 ++++++--------- .../main/folder/ThreadListMultiSelection.kt | 3 +- .../mail/ui/main/search/SearchFragment.kt | 75 +++++++------------ .../mail/utils/extensions/Extensions.kt | 14 ++++ 7 files changed, 175 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeActionHost.kt create mode 100644 app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeActionHostFactory.kt diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index be4c0dcd0b..6888533a49 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -17,8 +17,6 @@ */ package com.infomaniak.mail.ui.main.folder -import androidx.fragment.app.Fragment -import androidx.navigation.NavDirections import androidx.navigation.fragment.findNavController import com.infomaniak.core.fragmentnavigation.safelyNavigate import com.infomaniak.core.matomo.Matomo.TrackerAction @@ -31,36 +29,15 @@ import com.infomaniak.mail.data.models.SwipeAction import com.infomaniak.mail.data.models.isSnoozed import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.data.models.thread.Thread -import com.infomaniak.mail.ui.MainViewModel -import com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog import com.infomaniak.mail.ui.main.settings.appearance.swipe.SwipeActionsSettingsFragment import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType -import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel import com.infomaniak.mail.utils.extensions.animatedNavigation import com.infomaniak.mail.utils.extensions.archiveWithConfirmationPopup import com.infomaniak.mail.utils.extensions.deleteWithConfirmationPopup import com.infomaniak.mail.utils.extensions.getAnimatedNavOptions import com.infomaniak.mail.utils.extensions.moveWithConfirmationPopup -import io.realm.kotlin.types.RealmInstant object PerformSwipeActionManager { - - interface SwipeActionHost { - val fragment: Fragment - val mainViewModel: MainViewModel - val actionsViewModel: ActionsViewModel - val localSettings: LocalSettings - val threadListAdapter: ThreadListAdapter - val descriptionDialog: DescriptionAlertDialog - - fun showSwipeActionIncompatible() - - fun directionsToMove(threadUid: String, sourceFolderId: String): NavDirections - fun directionsToQuickActions(threadUid: String): NavDirections - - fun navigateToSnoozeBottomSheet(snoozeScheduleType: SnoozeScheduleType?, snoozeEndDate: RealmInstant?) - } - /** * The boolean return value is used to know if we should keep the Thread in * the RecyclerView (true), or remove it when the swipe is done (false). diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeActionHost.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeActionHost.kt new file mode 100644 index 0000000000..d28a38c559 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeActionHost.kt @@ -0,0 +1,43 @@ +/* + * 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 androidx.fragment.app.Fragment +import androidx.navigation.NavDirections +import com.infomaniak.mail.data.LocalSettings +import com.infomaniak.mail.ui.MainViewModel +import com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog +import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType +import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel +import io.realm.kotlin.types.RealmInstant + +interface SwipeActionHost { + val fragment: Fragment + val mainViewModel: MainViewModel + val actionsViewModel: ActionsViewModel + val localSettings: LocalSettings + val threadListAdapter: ThreadListAdapter + val descriptionDialog: DescriptionAlertDialog + + fun showSwipeActionIncompatible() + + fun directionsToMove(threadUid: String, sourceFolderId: String): NavDirections + fun directionsToQuickActions(threadUid: String): NavDirections + + fun navigateToSnoozeBottomSheet(snoozeScheduleType: SnoozeScheduleType?, snoozeEndDate: RealmInstant?) +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeActionHostFactory.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeActionHostFactory.kt new file mode 100644 index 0000000000..ba6d4eea73 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeActionHostFactory.kt @@ -0,0 +1,68 @@ +/* + * 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 androidx.fragment.app.Fragment +import androidx.navigation.NavDirections +import com.infomaniak.mail.data.LocalSettings +import com.infomaniak.mail.ui.MainViewModel +import com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog +import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType +import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel +import io.realm.kotlin.types.RealmInstant + +object SwipeActionHostFactory { + fun create( + fragment: Fragment, + mainViewModel: MainViewModel, + actionsViewModel: ActionsViewModel, + localSettings: LocalSettings, + threadListAdapter: ThreadListAdapter, + descriptionDialog: DescriptionAlertDialog, + showSwipeActionIncompatible: () -> Unit, + directionsToMove: (threadUid: String, sourceFolderId: String) -> NavDirections, + directionsToQuickActions: (threadUid: String) -> NavDirections, + navigateToSnoozeBottomSheet: (SnoozeScheduleType?, RealmInstant?) -> Unit, + ): SwipeActionHost { + return object : SwipeActionHost { + override val fragment = fragment + override val mainViewModel = mainViewModel + override val actionsViewModel = actionsViewModel + override val localSettings = localSettings + override val threadListAdapter = threadListAdapter + override val descriptionDialog = descriptionDialog + + override fun showSwipeActionIncompatible() = showSwipeActionIncompatible() + + override fun directionsToMove(threadUid: String, sourceFolderId: String): NavDirections { + return directionsToMove(threadUid, sourceFolderId) + } + + override fun directionsToQuickActions(threadUid: String): NavDirections { + return directionsToQuickActions(threadUid) + } + + override fun navigateToSnoozeBottomSheet( + snoozeScheduleType: SnoozeScheduleType?, + snoozeEndDate: RealmInstant?, + ) { + navigateToSnoozeBottomSheet(snoozeScheduleType, snoozeEndDate) + } + } + } +} 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 5ae9f9fe7e..b6d49f0b6d 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 @@ -35,7 +35,6 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePaddingRelative -import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -96,7 +95,6 @@ import com.infomaniak.mail.ui.main.emojiPicker.PickerEmojiObserver import com.infomaniak.mail.ui.main.folder.ThreadListViewModel.ContentDisplayMode import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.thread.ThreadFragment -import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType import com.infomaniak.mail.ui.main.thread.actions.EmojiReactionsViewModel import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionBinding import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionHost @@ -124,8 +122,8 @@ import com.infomaniak.mail.utils.extensions.safeArea import com.infomaniak.mail.utils.extensions.safeNavigateToNewMessageActivity import com.infomaniak.mail.utils.extensions.shareString import com.infomaniak.mail.utils.extensions.toDate +import com.infomaniak.mail.utils.extensions.updateSwipeAvailability import dagger.hilt.android.AndroidEntryPoint -import io.realm.kotlin.types.RealmInstant import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.util.Date @@ -370,16 +368,8 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio _binding = null } - override fun unlockSwipeActionsIfSet() = with(binding.threadsList) { - val isMultiSelectClosed = multiselectionViewModel.isMultiSelectOn.not() - - val isLeftSet = localSettings.swipeLeft != SwipeAction.NONE - val isLeftEnabled = isLeftSet && isMultiSelectClosed - if (isLeftEnabled) enableSwipeDirection(DirectionFlag.LEFT) else disableSwipeDirection(DirectionFlag.LEFT) - - val isRightSet = localSettings.swipeRight != SwipeAction.NONE - val isRightEnabled = isRightSet && isMultiSelectClosed - if (isRightEnabled) enableSwipeDirection(DirectionFlag.RIGHT) else disableSwipeDirection(DirectionFlag.RIGHT) + override fun unlockSwipeActionsIfSet() { + binding.threadsList.updateSwipeAvailability(localSettings, multiselectionViewModel.isMultiSelectOn) } override fun directionToThreadActionsBottomSheetDialog( @@ -650,43 +640,36 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio } return true } - val host = object : PerformSwipeActionManager.SwipeActionHost { - override val fragment: Fragment = this@ThreadListFragment - override val mainViewModel = this@ThreadListFragment.mainViewModel - override val actionsViewModel = this@ThreadListFragment.actionsViewModel - override val localSettings = this@ThreadListFragment.localSettings - override val threadListAdapter = this@ThreadListFragment.threadListAdapter - override val descriptionDialog = this@ThreadListFragment.descriptionDialog - - override fun showSwipeActionIncompatible() { + val host = SwipeActionHostFactory.create( + fragment = this@ThreadListFragment, + mainViewModel = mainViewModel, + actionsViewModel = actionsViewModel, + localSettings = localSettings, + threadListAdapter = threadListAdapter, + descriptionDialog = descriptionDialog, + showSwipeActionIncompatible = { snackbarManager.setValue(getString(R.string.snackbarSwipeActionIncompatible)) - } - - override fun directionsToMove(threadUid: String, sourceFolderId: String): NavDirections { - return ThreadListFragmentDirections.actionThreadListFragmentToFolderPickerFragment( + }, + directionsToMove = { threadUid, sourceFolderId -> + ThreadListFragmentDirections.actionThreadListFragmentToFolderPickerFragment( threadsUids = arrayOf(threadUid), action = FolderPickerAction.MOVE, sourceFolderId = sourceFolderId, isFromSearch = false, ) - } - - override fun directionsToQuickActions(threadUid: String): NavDirections { - return ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( + }, + directionsToQuickActions = { threadUid -> + ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( threadUid = threadUid, shouldLoadDistantResources = false, shouldCloseMultiSelection = false, isFromSearch = false, ) - } - - override fun navigateToSnoozeBottomSheet( - snoozeScheduleType: SnoozeScheduleType?, - snoozeEndDate: RealmInstant?, - ) { - this@ThreadListFragment.navigateToSnoozeBottomSheet(snoozeScheduleType, snoozeEndDate) - } - } + }, + navigateToSnoozeBottomSheet = { snoozeScheduleType, snoozeEndDate -> + navigateToSnoozeBottomSheet(snoozeScheduleType, snoozeEndDate) + }, + ) performSwipeAction(host, swipeAction, thread, position, isPermanentDeleteFolder, currentMailbox) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index c661233f7a..3a7d6c8559 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -80,7 +80,8 @@ class ThreadListMultiSelection { this.localSettings = localSettings this.isFromSearch = isFromSearch - if (isFromSearch && searchViewModel != null) { + if (isFromSearch) { + requireNotNull(searchViewModel) { "searchViewModel is required when isFromSearch is true" } this.searchViewModel = searchViewModel } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 7b6339d317..04854f6aae 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -29,7 +29,6 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePaddingRelative import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -71,6 +70,7 @@ import com.infomaniak.mail.ui.alertDialogs.DescriptionAlertDialog import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.folder.MultiSelectionListener import com.infomaniak.mail.ui.main.folder.PerformSwipeActionManager +import com.infomaniak.mail.ui.main.folder.SwipeActionHostFactory import com.infomaniak.mail.ui.main.folder.ThreadListAdapterCallbacks import com.infomaniak.mail.ui.main.folder.ThreadListItem import com.infomaniak.mail.ui.main.folder.ThreadListMultiSelection @@ -78,7 +78,6 @@ import com.infomaniak.mail.ui.main.folder.ThreadListViewModel import com.infomaniak.mail.ui.main.folder.TwoPaneFragment import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.thread.ThreadFragment -import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionBinding import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionHost import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel @@ -94,8 +93,8 @@ import com.infomaniak.mail.utils.extensions.handleEditorSearchAction import com.infomaniak.mail.utils.extensions.safeArea import com.infomaniak.mail.utils.extensions.safelyAnimatedNavigation import com.infomaniak.mail.utils.extensions.setOnClearTextClickListener +import com.infomaniak.mail.utils.extensions.updateSwipeAvailability import dagger.hilt.android.AndroidEntryPoint -import io.realm.kotlin.types.RealmInstant import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -127,16 +126,8 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { binding.mailRecyclerView.disableSwipeDirection(direction) } - override fun unlockSwipeActionsIfSet() = with(binding.mailRecyclerView) { - val isMultiSelectClosed = multiselectionViewModel.isMultiSelectOn.not() - - val isLeftSet = localSettings.swipeLeft != SwipeAction.NONE - val isLeftEnabled = isLeftSet && isMultiSelectClosed - if (isLeftEnabled) enableSwipeDirection(DirectionFlag.LEFT) else disableSwipeDirection(DirectionFlag.LEFT) - - val isRightSet = localSettings.swipeRight != SwipeAction.NONE - val isRightEnabled = isRightSet && isMultiSelectClosed - if (isRightEnabled) enableSwipeDirection(DirectionFlag.RIGHT) else disableSwipeDirection(DirectionFlag.RIGHT) + override fun unlockSwipeActionsIfSet() { + binding.mailRecyclerView.updateSwipeAvailability(localSettings, multiselectionViewModel.isMultiSelectOn) } override fun directionToThreadActionsBottomSheetDialog( @@ -470,52 +461,38 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { return true } - val host = object : PerformSwipeActionManager.SwipeActionHost { - override val fragment: Fragment = this@SearchFragment - override val mainViewModel = this@SearchFragment.mainViewModel - override val actionsViewModel = this@SearchFragment.actionsViewModel - override val localSettings = this@SearchFragment.localSettings - override val threadListAdapter = this@SearchFragment.threadListAdapter - override val descriptionDialog = this@SearchFragment.descriptionDialog - - override fun showSwipeActionIncompatible() { + val host = SwipeActionHostFactory.create( + fragment = this@SearchFragment, + mainViewModel = mainViewModel, + actionsViewModel = actionsViewModel, + localSettings = localSettings, + threadListAdapter = threadListAdapter, + descriptionDialog = descriptionDialog, + showSwipeActionIncompatible = { snackbarManager.setValue(getString(R.string.snackbarSwipeActionIncompatible)) - } - - override fun directionsToMove(threadUid: String, sourceFolderId: String): NavDirections { - return SearchFragmentDirections.actionSearchFragmentToFolderPickerFragment( + }, + directionsToMove = { threadUid, sourceFolderId -> + SearchFragmentDirections.actionSearchFragmentToFolderPickerFragment( threadsUids = arrayOf(threadUid), action = FolderPickerAction.MOVE, sourceFolderId = sourceFolderId, - isFromSearch = true, + isFromSearch = false, ) - } - - override fun directionsToQuickActions(threadUid: String): NavDirections { - return SearchFragmentDirections.actionSearchFragmentToThreadActionsBottomSheetDialog( + }, + directionsToQuickActions = { threadUid -> + SearchFragmentDirections.actionSearchFragmentToThreadActionsBottomSheetDialog( threadUid = threadUid, shouldLoadDistantResources = false, shouldCloseMultiSelection = false, - isFromSearch = true, + isFromSearch = false, ) - } - - override fun navigateToSnoozeBottomSheet( - snoozeScheduleType: SnoozeScheduleType?, - snoozeEndDate: RealmInstant?, - ) { - this@SearchFragment.navigateToSnoozeBottomSheet(snoozeScheduleType, snoozeEndDate) - } - } - - return performSwipeAction( - host = host, - swipeAction = swipeAction, - thread = thread, - position = position, - isPermanentDeleteFolder = isPermanentDeleteFolder, - currentMailbox = currentMailbox + }, + navigateToSnoozeBottomSheet = { snoozeScheduleType, snoozeEndDate -> + navigateToSnoozeBottomSheet(snoozeScheduleType, snoozeEndDate) + }, ) + + performSwipeAction(host, swipeAction, thread, position, isPermanentDeleteFolder, currentMailbox) } private fun setAllFoldersButtonListener() { diff --git a/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt b/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt index 5cf67c50b1..8babc1a115 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt @@ -78,9 +78,11 @@ import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError import com.infomaniak.core.sentry.SentryLog import com.infomaniak.core.ui.showToast import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView +import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation.DirectionFlag import com.infomaniak.lib.login.InfomaniakLogin import com.infomaniak.mail.BuildConfig import com.infomaniak.mail.R +import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.LocalSettings.ThreadDensity import com.infomaniak.mail.data.cache.mailboxContent.FolderController import com.infomaniak.mail.data.cache.mailboxContent.ImpactedFolders @@ -88,6 +90,7 @@ import com.infomaniak.mail.data.models.Attachment import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.SnoozeState +import com.infomaniak.mail.data.models.SwipeAction import com.infomaniak.mail.data.models.correspondent.Correspondent import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.correspondent.Recipient @@ -410,6 +413,17 @@ fun DragDropSwipeRecyclerView.addStickyDateDecoration(adapter: ThreadListAdapter if (threadDensity == ThreadDensity.NORMAL) addItemDecoration(DateSeparatorItemDecoration()) } +fun DragDropSwipeRecyclerView.updateSwipeAvailability( + localSettings: LocalSettings, + isMultiSelectOn: Boolean, +) { + val isLeftEnabled = localSettings.swipeLeft != SwipeAction.NONE && !isMultiSelectOn + if (isLeftEnabled) enableSwipeDirection(DirectionFlag.LEFT) else disableSwipeDirection(DirectionFlag.LEFT) + + val isRightEnabled = localSettings.swipeRight != SwipeAction.NONE && !isMultiSelectOn + if (isRightEnabled) enableSwipeDirection(DirectionFlag.RIGHT) else disableSwipeDirection(DirectionFlag.RIGHT) +} + fun Context.getLocalizedNameOrAllFolders(folder: Folder?): String { return folder?.getLocalizedName(context = this) ?: getString(R.string.searchFilterFolder) } From 48dfa434bbe24c1b3f146ba65c871e36cdbde8ae Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 10:22:00 +0200 Subject: [PATCH 30/52] fix: Make sure there is space for banners to show, let the recycler view take the space available --- app/src/main/res/layout/fragment_thread_list.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/layout/fragment_thread_list.xml b/app/src/main/res/layout/fragment_thread_list.xml index 42e74fa400..cb4dd4310d 100644 --- a/app/src/main/res/layout/fragment_thread_list.xml +++ b/app/src/main/res/layout/fragment_thread_list.xml @@ -173,7 +173,8 @@ Date: Fri, 29 May 2026 10:22:25 +0200 Subject: [PATCH 31/52] refactor: Improve setupMultiselectActions --- .../mail/ui/main/folder/ThreadListMultiSelection.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index 3a7d6c8559..a220f0d978 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -94,6 +94,7 @@ class ThreadListMultiSelection { val selectedThreadsUids = selectedThreads.map { it.uid } val selectedThreadsCount = selectedThreadsUids.count() val currentMailBox = mainViewModel.currentMailbox.value ?: return@setOnItemClickListener + val currentFolderId = if (isFromSearch) searchViewModel.filterFolder?.id else mainViewModel.currentFolderId when (menuId) { R.id.quickActionUnread -> { @@ -101,7 +102,7 @@ class ThreadListMultiSelection { actionsViewModel.toggleThreadsSeenStatus( threadsUids = selectedThreadsUids, shouldRead = shouldMultiselectRead, - currentFolderId = if (isFromSearch) searchViewModel.filterFolder?.id else mainViewModel.currentFolderId, + currentFolderId = currentFolderId, mailbox = currentMailBox, ) isMultiSelectOn = false @@ -114,7 +115,7 @@ class ThreadListMultiSelection { trackMultiSelectActionEvent(MatomoName.Archive, selectedThreadsCount) actionsViewModel.archiveThreads( threads = selectedThreads.toList(), - currentFolderId = if (isFromSearch) searchViewModel.filterFolder?.id else mainViewModel.currentFolderId, + currentFolderId = currentFolderId, mailbox = currentMailBox, ) isMultiSelectOn = false @@ -139,7 +140,7 @@ class ThreadListMultiSelection { trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) actionsViewModel.deleteThreads( selectedThreads.toList(), - if (isFromSearch) searchViewModel.filterFolder?.id else mainViewModel.currentFolderId, + currentFolderId, currentMailBox ) isMultiSelectOn = false From 5cb94b5bae3b90edb96dea15a74eebe0c3cbee1d Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 10:37:25 +0200 Subject: [PATCH 32/52] refactor: Improve code --- .../main/folder/ThreadListMultiSelection.kt | 6 +- .../mail/ui/main/search/SearchViewModel.kt | 8 +- .../actions/MultiSelectBottomSheetDialog.kt | 96 ++++++++++--------- 3 files changed, 58 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index a220f0d978..9ee95c5794 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -138,11 +138,7 @@ class ThreadListMultiSelection { count = selectedThreadsCount, ) { trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) - actionsViewModel.deleteThreads( - selectedThreads.toList(), - currentFolderId, - currentMailBox - ) + actionsViewModel.deleteThreads(selectedThreads.toList(), currentFolderId, currentMailBox) isMultiSelectOn = false } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index f3bed7615a..4f7cd5b185 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -216,7 +216,9 @@ class SearchViewModel @Inject constructor( filter.unselect() } - private fun shouldShowContacts(): Boolean { + private fun shouldShowContacts(withContacts: Boolean): Boolean { + if (!withContacts) return false + val hasQuery = currentSearchQuery.isNotBlank() val hasNoFilters = currentFilters.isEmpty() val notValidated = currentUiState != SearchUiState.VALIDATED @@ -338,10 +340,10 @@ class SearchViewModel @Inject constructor( delay(SEARCH_DEBOUNCE_DURATION) ensureActive() - val showContacts = shouldShowContacts() && + val showContacts = shouldShowContacts(withContacts) && query.isNotBlank() && !query.contains("\"") && - !isLengthTooShort(query) && withContacts + !isLengthTooShort(query) val contacts = if (showContacts) { val searchQueryNoAccents = Normalizer.normalize(query, Normalizer.Form.NFD) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index fde289cc13..e9efbf1532 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -40,6 +40,7 @@ import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.isSnoozed +import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.databinding.BottomSheetMultiSelectBinding import com.infomaniak.mail.ui.MainViewModel @@ -161,51 +162,9 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { isMultiSelectOn = false } - binding.phishing.setOnClickListener { - trackMultiSelectActionEvent(MatomoName.SignalPhishing, threadsCount, isFromBottomSheet = true) - val messages = junkMessagesViewModel.junkMessages.value ?: emptyList() + binding.phishing.setOnClickListener { handlePhishing(threadsCount, currentMailbox) } - if (messages.isEmpty()) { - //An error will be shown to the user in the reportPhishing function - //This should never happen, that's why we add a SentryLog. - SentryLog.e(TAG, getString(R.string.sentryErrorPhishingMessagesEmpty)) - } - - descriptionDialog.show( - title = getString(R.string.reportPhishingTitle), - description = resources.getQuantityString(R.plurals.reportPhishingDescription, messages.count()), - onPositiveButtonClicked = { - actionsViewModel.reportPhishing( - messages = messages, - currentFolderId = mainViewModel.currentFolderId, - mailbox = currentMailbox, - ) - }, - ) - isMultiSelectOn = false - } - - binding.blockSender.setClosingOnClickListener { - trackMultiSelectActionEvent(MatomoName.BlockUser, threadsCount, isFromBottomSheet = true) - val potentialUsersToBlock = junkMessagesViewModel.potentialBlockedUsers.value - if (potentialUsersToBlock == null) { - snackbarManager.postValue(getString(RCore.string.anErrorHasOccurred)) - SentryLog.e(TAG, getString(R.string.sentryErrorPotentialUsersToBlockNull)) - return@setClosingOnClickListener - } - - if (potentialUsersToBlock.count() > 1) { - safelyNavigate( - resId = R.id.userToBlockBottomSheetDialog, - substituteClassName = ThreadListFragment::class.java.name, - ) - } else { - potentialUsersToBlock.values.firstOrNull()?.let { message -> - junkMessagesViewModel.messageOfUserToBlock.value = message - } - } - isMultiSelectOn = false - } + binding.blockSender.setClosingOnClickListener { handleBlockSender(threadsCount) } binding.favorite.setClosingOnClickListener(shouldCloseMultiSelection = true) { trackMultiSelectActionEvent(MatomoName.Favorite, threadsCount, isFromBottomSheet = true) @@ -225,6 +184,55 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { } } + private fun handlePhishing( + threadsCount: Int, + currentMailbox: Mailbox + ) { + trackMultiSelectActionEvent(MatomoName.SignalPhishing, threadsCount, isFromBottomSheet = true) + val messages = junkMessagesViewModel.junkMessages.value ?: emptyList() + + if (messages.isEmpty()) { + //An error will be shown to the user in the reportPhishing function + //This should never happen, that's why we add a SentryLog. + SentryLog.e(TAG, getString(R.string.sentryErrorPhishingMessagesEmpty)) + } + + descriptionDialog.show( + title = getString(R.string.reportPhishingTitle), + description = resources.getQuantityString(R.plurals.reportPhishingDescription, messages.count()), + onPositiveButtonClicked = { + actionsViewModel.reportPhishing( + messages = messages, + currentFolderId = mainViewModel.currentFolderId, + mailbox = currentMailbox, + ) + }, + ) + multiselectionViewModel.isMultiSelectOn = false + } + + private fun handleBlockSender(threadsCount: Int) { + trackMultiSelectActionEvent(MatomoName.BlockUser, threadsCount, isFromBottomSheet = true) + val potentialUsersToBlock = junkMessagesViewModel.potentialBlockedUsers.value + if (potentialUsersToBlock == null) { + snackbarManager.postValue(getString(RCore.string.anErrorHasOccurred)) + SentryLog.e(TAG, getString(R.string.sentryErrorPotentialUsersToBlockNull)) + return + } + + if (potentialUsersToBlock.count() > 1) { + safelyNavigate( + resId = R.id.userToBlockBottomSheetDialog, + substituteClassName = ThreadListFragment::class.java.name, + ) + } else { + potentialUsersToBlock.values.firstOrNull()?.let { message -> + junkMessagesViewModel.messageOfUserToBlock.value = message + } + } + multiselectionViewModel.isMultiSelectOn = false + } + private fun setupMainActions( threads: Set, threadsUids: List, From 24055e61106c154fde8fde5a4e60445a7dc4383d Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 11:15:18 +0200 Subject: [PATCH 33/52] refactor: Add missing commas --- .../ui/main/folder/PerformSwipeActionManager.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index 6888533a49..e94aee3d33 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -129,7 +129,7 @@ object PerformSwipeActionManager { threadsUids = listOf(thread.uid), shouldRead = !thread.isSeen, currentFolderId = thread.folderId, - mailbox = currentMailbox + mailbox = currentMailbox, ) true } @@ -138,7 +138,7 @@ object PerformSwipeActionManager { host.actionsViewModel.toggleThreadsSpamStatus( threads = setOf(thread), currentFolderId = thread.folderId, - mailbox = currentMailbox + mailbox = currentMailbox, ) false } @@ -162,7 +162,7 @@ object PerformSwipeActionManager { thread: Thread, position: Int, folderRole: FolderRole?, - currentMailBox: Mailbox + currentMailBox: Mailbox, ): Boolean { fun onCancel() { // Notify only if the user cancelled the popup (e.g. the thread is not deleted), @@ -176,7 +176,7 @@ object PerformSwipeActionManager { host.actionsViewModel.archiveThreads( threads = listOf(thread), currentFolderId = thread.folderId, - mailbox = currentMailBox + mailbox = currentMailBox, ) } @@ -185,7 +185,7 @@ object PerformSwipeActionManager { count = 1, displayLoader = false, onCancel = ::onCancel, - onPositiveButtonClicked = ::onSuccess + onPositiveButtonClicked = ::onSuccess, ) } @@ -194,7 +194,7 @@ object PerformSwipeActionManager { thread: Thread, position: Int, isPermanentDeleteFolder: Boolean, - currentMailBox: Mailbox + currentMailBox: Mailbox, ): Boolean { fun onCancel() { // Notify only if the user cancelled the popup (e.g. the thread is not deleted), @@ -209,7 +209,7 @@ object PerformSwipeActionManager { host.actionsViewModel.deleteThreads( threads = listOf(thread), currentFolderId = thread.folderId, - mailbox = currentMailBox + mailbox = currentMailBox, ) } From 941b8221ae5a8fe45ea5a6e6d7e0f776311ebcbf Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 11:15:56 +0200 Subject: [PATCH 34/52] refactor: Create extensions for duplicated code in swipe actions --- .../mail/ui/main/folder/ThreadListFragment.kt | 22 +---------- .../mail/ui/main/search/SearchFragment.kt | 24 ++---------- .../mail/utils/extensions/Extensions.kt | 38 +++++++++++++++++++ 3 files changed, 43 insertions(+), 41 deletions(-) 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 b6d49f0b6d..247a82e836 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 @@ -122,6 +122,7 @@ import com.infomaniak.mail.utils.extensions.safeArea import com.infomaniak.mail.utils.extensions.safeNavigateToNewMessageActivity import com.infomaniak.mail.utils.extensions.shareString import com.infomaniak.mail.utils.extensions.toDate +import com.infomaniak.mail.utils.extensions.updateSwipeActionsUi import com.infomaniak.mail.utils.extensions.updateSwipeAvailability import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.first @@ -482,26 +483,7 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio } private fun updateDisabledSwipeActionsUi(featureFlags: FeatureFlagSet?, folderRole: FolderRole?) { - val isLeftEnabled = localSettings.swipeLeft.canDisplay(folderRole, featureFlags, localSettings) - val isRightEnabled = localSettings.swipeRight.canDisplay(folderRole, featureFlags, localSettings) - - setSwipeActionEnabledUi(DirectionFlag.LEFT, isLeftEnabled) - setSwipeActionEnabledUi(DirectionFlag.RIGHT, isRightEnabled) - } - - private fun setSwipeActionEnabledUi(swipeDirection: DirectionFlag, isEnabled: Boolean) = with(binding.threadsList) { - fun SwipeAction.getIconRes(): Int? = if (isEnabled) iconRes else R.drawable.ic_close_small - fun SwipeAction.getBackgroundColor(): Int { - return if (isEnabled) getBackgroundColor(context) else SwipeAction.NONE.getBackgroundColor(context) - } - - if (swipeDirection == DirectionFlag.LEFT) { - behindSwipedItemIconDrawableId = localSettings.swipeLeft.getIconRes() - behindSwipedItemBackgroundColor = localSettings.swipeLeft.getBackgroundColor() - } else { - behindSwipedItemIconSecondaryDrawableId = localSettings.swipeRight.getIconRes() - behindSwipedItemBackgroundSecondaryColor = localSettings.swipeRight.getBackgroundColor() - } + binding.threadsList.updateSwipeActionsUi(localSettings, featureFlags, folderRole) } private fun setupListeners() = with(binding) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 04854f6aae..90a9749cd9 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -93,6 +93,7 @@ import com.infomaniak.mail.utils.extensions.handleEditorSearchAction import com.infomaniak.mail.utils.extensions.safeArea import com.infomaniak.mail.utils.extensions.safelyAnimatedNavigation import com.infomaniak.mail.utils.extensions.setOnClearTextClickListener +import com.infomaniak.mail.utils.extensions.updateSwipeActionsUi import com.infomaniak.mail.utils.extensions.updateSwipeAvailability import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest @@ -152,7 +153,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { threadsUids: Array, messagesUids: Array?, action: FolderPickerAction, - sourceFolderId: String? + sourceFolderId: String?, ): NavDirections { return SearchFragmentDirections.actionSearchFragmentToFolderPickerFragment( threadsUids = threadsUids, @@ -319,26 +320,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { } private fun updateDisabledSwipeActionsUi(featureFlags: FeatureFlagSet?, folderRole: FolderRole?) { - val isLeftEnabled = localSettings.swipeLeft.canDisplay(folderRole, featureFlags, localSettings) - val isRightEnabled = localSettings.swipeRight.canDisplay(folderRole, featureFlags, localSettings) - - setSwipeActionEnabledUi(DirectionFlag.LEFT, isLeftEnabled) - setSwipeActionEnabledUi(DirectionFlag.RIGHT, isRightEnabled) - } - - private fun setSwipeActionEnabledUi(swipeDirection: DirectionFlag, isEnabled: Boolean) = with(binding.mailRecyclerView) { - fun SwipeAction.getIconRes(): Int? = if (isEnabled) iconRes else R.drawable.ic_close_small - fun SwipeAction.getBackgroundColor(): Int { - return if (isEnabled) getBackgroundColor(context) else SwipeAction.NONE.getBackgroundColor(context) - } - - if (swipeDirection == DirectionFlag.LEFT) { - behindSwipedItemIconDrawableId = localSettings.swipeLeft.getIconRes() - behindSwipedItemBackgroundColor = localSettings.swipeLeft.getBackgroundColor() - } else { - behindSwipedItemIconSecondaryDrawableId = localSettings.swipeRight.getIconRes() - behindSwipedItemBackgroundSecondaryColor = localSettings.swipeRight.getBackgroundColor() - } + binding.mailRecyclerView.updateSwipeActionsUi(localSettings, featureFlags, folderRole) } private fun setupAdapter() { diff --git a/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt b/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt index 8babc1a115..2765528039 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt @@ -96,6 +96,7 @@ import com.infomaniak.mail.data.models.correspondent.MergedContact import com.infomaniak.mail.data.models.correspondent.Recipient import com.infomaniak.mail.data.models.draft.Draft.DraftMode import com.infomaniak.mail.data.models.javascriptBridge.EditorJavascriptBridge +import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.data.models.message.Message import com.infomaniak.mail.data.models.signature.Signature import com.infomaniak.mail.data.models.thread.Thread @@ -424,6 +425,43 @@ fun DragDropSwipeRecyclerView.updateSwipeAvailability( if (isRightEnabled) enableSwipeDirection(DirectionFlag.RIGHT) else disableSwipeDirection(DirectionFlag.RIGHT) } +fun DragDropSwipeRecyclerView.updateSwipeActionEnabledUi( + swipeAction: SwipeAction, + swipeDirection: DirectionFlag, + isEnabled: Boolean, +) { + fun SwipeAction.iconResOrDisabled(): Int? = if (isEnabled) iconRes else R.drawable.ic_close_small + fun SwipeAction.backgroundColorOrDisabled(): Int = + if (isEnabled) getBackgroundColor(context) else SwipeAction.NONE.getBackgroundColor(context) + + if (swipeDirection == DirectionFlag.LEFT) { + behindSwipedItemIconDrawableId = swipeAction.iconResOrDisabled() + behindSwipedItemBackgroundColor = swipeAction.backgroundColorOrDisabled() + } else if (swipeDirection == DirectionFlag.RIGHT) { + behindSwipedItemIconSecondaryDrawableId = swipeAction.iconResOrDisabled() + behindSwipedItemBackgroundSecondaryColor = swipeAction.backgroundColorOrDisabled() + } +} + +fun DragDropSwipeRecyclerView.updateSwipeActionsUi( + localSettings: LocalSettings, + featureFlags: Mailbox.FeatureFlagSet?, + folderRole: FolderRole? +) { + apply { + updateSwipeActionEnabledUi( + swipeAction = localSettings.swipeLeft, + swipeDirection = DirectionFlag.LEFT, + isEnabled = localSettings.swipeLeft.canDisplay(folderRole, featureFlags, localSettings), + ) + updateSwipeActionEnabledUi( + swipeAction = localSettings.swipeRight, + swipeDirection = DirectionFlag.RIGHT, + isEnabled = localSettings.swipeRight.canDisplay(folderRole, featureFlags, localSettings), + ) + } +} + fun Context.getLocalizedNameOrAllFolders(folder: Folder?): String { return folder?.getLocalizedName(context = this) ?: getString(R.string.searchFilterFolder) } From 31aa29f5a6ed083797b43465f010bb9f17dd1164 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 11:24:10 +0200 Subject: [PATCH 35/52] fix: Is from search directions --- .../java/com/infomaniak/mail/ui/main/search/SearchFragment.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 90a9749cd9..fafe18b1c0 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -458,7 +458,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { threadsUids = arrayOf(threadUid), action = FolderPickerAction.MOVE, sourceFolderId = sourceFolderId, - isFromSearch = false, + isFromSearch = true, ) }, directionsToQuickActions = { threadUid -> @@ -466,7 +466,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { threadUid = threadUid, shouldLoadDistantResources = false, shouldCloseMultiSelection = false, - isFromSearch = false, + isFromSearch = true, ) }, navigateToSnoozeBottomSheet = { snoozeScheduleType, snoozeEndDate -> From 5b50fdf06515cc848e175d769389e52ef5a26a9f Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 11:25:32 +0200 Subject: [PATCH 36/52] fix: Change channel for search refresh to confluated --- .../mail/ui/main/thread/actions/ActionsViewModel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index f025ca5143..3d9d33c64d 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -92,11 +92,11 @@ class ActionsViewModel @Inject constructor( val reportPhishingTrigger = SingleLiveEvent() //region refreshSearch - private val _searchRefreshEvents = Channel(Channel.BUFFERED) + private val _searchRefreshEvents = Channel(Channel.CONFLATED) val searchRefreshEvents = _searchRefreshEvents.receiveAsFlow() - private suspend fun notifySearchRefresh() { - _searchRefreshEvents.send(Unit) + private fun notifySearchRefresh() { + _searchRefreshEvents.trySend(Unit) } //endregion From 31b632a09847457abb99028423de28b376cfcba7 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 12:50:54 +0200 Subject: [PATCH 37/52] feat: Add ThreadSwipeListenerFactory to remove duplicated objects --- .../mail/ui/main/folder/ThreadListFragment.kt | 39 ++--------- .../main/folder/ThreadSwipeListenerFactory.kt | 68 +++++++++++++++++++ .../mail/ui/main/search/SearchFragment.kt | 45 +++--------- 3 files changed, 82 insertions(+), 70 deletions(-) create mode 100644 app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadSwipeListenerFactory.kt 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 247a82e836..a065319058 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 @@ -61,8 +61,6 @@ import com.infomaniak.core.legacy.utils.setPaddingRelative import com.infomaniak.core.sentry.SentryLog import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation.DirectionFlag -import com.infomaniak.dragdropswiperecyclerview.listener.OnItemSwipeListener -import com.infomaniak.dragdropswiperecyclerview.listener.OnItemSwipeListener.SwipeDirection import com.infomaniak.dragdropswiperecyclerview.listener.OnListScrollListener import com.infomaniak.dragdropswiperecyclerview.listener.OnListScrollListener.ScrollDirection import com.infomaniak.dragdropswiperecyclerview.listener.OnListScrollListener.ScrollState @@ -109,7 +107,6 @@ import com.infomaniak.mail.utils.RealmChangesBinding.Companion.bindResultsChange import com.infomaniak.mail.utils.SentryDebug.displayForSentry import com.infomaniak.mail.utils.UiUtils.formatUnreadCount import com.infomaniak.mail.utils.Utils -import com.infomaniak.mail.utils.Utils.isPermanentDeleteFolder import com.infomaniak.mail.utils.Utils.runCatchingRealm import com.infomaniak.mail.utils.extensions.addStickyDateDecoration import com.infomaniak.mail.utils.extensions.applySideAndBottomSystemInsets @@ -541,36 +538,12 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver, MultiSelectio } } - threadsList.swipeListener = object : OnItemSwipeListener { - override fun onItemSwiped(position: Int, direction: SwipeDirection, item: ThreadListItem.Content): Boolean { - - val swipeAction = when (direction) { - SwipeDirection.LEFT_TO_RIGHT -> localSettings.swipeRight - SwipeDirection.RIGHT_TO_LEFT -> localSettings.swipeLeft - else -> error("Only SwipeDirection.LEFT_TO_RIGHT and SwipeDirection.RIGHT_TO_LEFT can be triggered") - } - - val isPermanentDeleteFolder = isPermanentDeleteFolder(item.thread.folder.role) - - val shouldKeepItem = performSwipeActionOnThread(swipeAction, item.thread, position, isPermanentDeleteFolder) - - threadListAdapter.apply { - blockOtherSwipes() - - if (swipeAction == SwipeAction.DELETE && isPermanentDeleteFolder) { - Unit // The swiped Thread stay swiped all the way - } else { - notifyItemChanged(position) // Animate the swiped Thread back to its original position - } - } - - threadListViewModel.isRecoveringFinished.value = false - - // The return value of this callback is used to determine if the - // swiped item should be kept or deleted from the adapter's list. - return shouldKeepItem - } - } + threadsList.swipeListener = ThreadSwipeListenerFactory.create( + localSettings = localSettings, + threadListAdapter = threadListAdapter, + onRecoveringStarted = { threadListViewModel.isRecoveringFinished.value = false }, + performSwipeActionOnThread = ::performSwipeActionOnThread, + ) } private fun setupGestureDetector(context: Context) = diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadSwipeListenerFactory.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadSwipeListenerFactory.kt new file mode 100644 index 0000000000..583d16e42a --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadSwipeListenerFactory.kt @@ -0,0 +1,68 @@ +/* + * 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 com.infomaniak.dragdropswiperecyclerview.listener.OnItemSwipeListener +import com.infomaniak.dragdropswiperecyclerview.listener.OnItemSwipeListener.SwipeDirection +import com.infomaniak.mail.data.LocalSettings +import com.infomaniak.mail.data.models.SwipeAction +import com.infomaniak.mail.data.models.thread.Thread +import com.infomaniak.mail.utils.Utils.isPermanentDeleteFolder + +object ThreadSwipeListenerFactory { + fun create( + localSettings: LocalSettings, + threadListAdapter: ThreadListAdapter, + onRecoveringStarted: () -> Unit, + performSwipeActionOnThread: ( + swipeAction: SwipeAction, + thread: Thread, + position: Int, + isPermanentDeleteFolder: Boolean, + ) -> Boolean, + ): OnItemSwipeListener { + return object : OnItemSwipeListener { + override fun onItemSwiped( + position: Int, + direction: SwipeDirection, + item: ThreadListItem.Content, + ): Boolean { + val swipeAction = when (direction) { + SwipeDirection.LEFT_TO_RIGHT -> localSettings.swipeRight + SwipeDirection.RIGHT_TO_LEFT -> localSettings.swipeLeft + else -> error("Only SwipeDirection.LEFT_TO_RIGHT and SwipeDirection.RIGHT_TO_LEFT can be triggered") + } + + val isPermanentDeleteFolder = isPermanentDeleteFolder(item.thread.folder.role) + val shouldKeepItem = + performSwipeActionOnThread(swipeAction, item.thread, position, isPermanentDeleteFolder) + + threadListAdapter.apply { + blockOtherSwipes() + + if (swipeAction != SwipeAction.DELETE || !isPermanentDeleteFolder) { + notifyItemChanged(position) + } + } + + onRecoveringStarted() + return shouldKeepItem + } + } + } +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index fafe18b1c0..e0629ae4f9 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -48,8 +48,6 @@ import com.infomaniak.core.legacy.utils.showKeyboard import com.infomaniak.core.sentry.SentryLog import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation.DirectionFlag -import com.infomaniak.dragdropswiperecyclerview.listener.OnItemSwipeListener -import com.infomaniak.dragdropswiperecyclerview.listener.OnItemSwipeListener.SwipeDirection import com.infomaniak.dragdropswiperecyclerview.listener.OnListScrollListener import com.infomaniak.dragdropswiperecyclerview.listener.OnListScrollListener.ScrollDirection import com.infomaniak.dragdropswiperecyclerview.listener.OnListScrollListener.ScrollState @@ -72,9 +70,9 @@ import com.infomaniak.mail.ui.main.folder.MultiSelectionListener import com.infomaniak.mail.ui.main.folder.PerformSwipeActionManager import com.infomaniak.mail.ui.main.folder.SwipeActionHostFactory import com.infomaniak.mail.ui.main.folder.ThreadListAdapterCallbacks -import com.infomaniak.mail.ui.main.folder.ThreadListItem import com.infomaniak.mail.ui.main.folder.ThreadListMultiSelection import com.infomaniak.mail.ui.main.folder.ThreadListViewModel +import com.infomaniak.mail.ui.main.folder.ThreadSwipeListenerFactory import com.infomaniak.mail.ui.main.folder.TwoPaneFragment import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.thread.ThreadFragment @@ -83,7 +81,6 @@ import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionH import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.Utils.Shortcuts -import com.infomaniak.mail.utils.Utils.isPermanentDeleteFolder import com.infomaniak.mail.utils.extensions.addStickyDateDecoration import com.infomaniak.mail.utils.extensions.applySideAndBottomSystemInsets import com.infomaniak.mail.utils.extensions.applyStatusBarInsets @@ -388,36 +385,12 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { threadListAdapter.updateSelection() } } - mailRecyclerView.swipeListener = object : OnItemSwipeListener { - override fun onItemSwiped(position: Int, direction: SwipeDirection, item: ThreadListItem.Content): Boolean { - - val swipeAction = when (direction) { - SwipeDirection.LEFT_TO_RIGHT -> localSettings.swipeRight - SwipeDirection.RIGHT_TO_LEFT -> localSettings.swipeLeft - else -> error("Only SwipeDirection.LEFT_TO_RIGHT and SwipeDirection.RIGHT_TO_LEFT can be triggered") - } - - val isPermanentDeleteFolder = isPermanentDeleteFolder(item.thread.folder.role) - - val shouldKeepItem = performSwipeActionOnThread(swipeAction, item.thread, position, isPermanentDeleteFolder) - - threadListAdapter.apply { - blockOtherSwipes() - - if (swipeAction == SwipeAction.DELETE && isPermanentDeleteFolder) { - Unit // The swiped Thread stay swiped all the way - } else { - notifyItemChanged(position) // Animate the swiped Thread back to its original position - } - } - - threadListViewModel.isRecoveringFinished.value = false - - // The return value of this callback is used to determine if the - // swiped item should be kept or deleted from the adapter's list. - return shouldKeepItem - } - } + mailRecyclerView.swipeListener = ThreadSwipeListenerFactory.create( + localSettings = localSettings, + threadListAdapter = threadListAdapter, + onRecoveringStarted = { threadListViewModel.isRecoveringFinished.value = false }, + performSwipeActionOnThread = ::performSwipeActionOnThread, + ) swipeRefreshLayout.setOnRefreshListener { searchViewModel.refreshSearch(withContacts = !multiselectionViewModel.isMultiSelectOn) @@ -656,9 +629,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { private fun observeMultiSelect() { multiselectionViewModel.isMultiSelectOnLiveData.observe(viewLifecycleOwner) { isMultiSelectOn -> - if (isMultiSelectOn) { - searchViewModel.contactsResults.value = emptyList() - } + if (isMultiSelectOn) searchViewModel.contactsResults.value = emptyList() val autoTransition = AutoTransition() autoTransition.duration = TOOLBAR_FADE_DURATION TransitionManager.beginDelayedTransition(binding.horizontalScrollViewFilters, autoTransition) From 119df988b2427926de63c0e4eaecce2b9c85cedb Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 13:26:01 +0200 Subject: [PATCH 38/52] refactor: Improve details --- .../mail/ui/main/thread/DetailedContactBottomSheetDialog.kt | 3 ++- .../infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt | 3 +-- .../ui/main/thread/actions/MultiSelectBottomSheetDialog.kt | 2 +- .../ui/main/thread/actions/UserToBlockBottomSheetDialog.kt | 2 -- .../main/thread/actions/multiselection/MultiselectionHost.kt | 2 +- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/DetailedContactBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/DetailedContactBottomSheetDialog.kt index 78ecddaf08..a1eeeef6dd 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/DetailedContactBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/DetailedContactBottomSheetDialog.kt @@ -45,7 +45,8 @@ class DetailedContactBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetDetailedContactBinding by safeBinding() private val navigationArgs: DetailedContactBottomSheetDialogArgs by navArgs() override val multiselectionViewModel: MultiselectionViewModel by activityViewModels() - val mainViewModel: MainViewModel by activityViewModels() + + private val mainViewModel: MainViewModel by activityViewModels() private val currentClassName: String by lazy { DetailedContactBottomSheetDialog::class.java.name } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index 3d9d33c64d..1952f3e006 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -241,7 +241,6 @@ class ActionsViewModel @Inject constructor( currentThread?.let { (_, uid) -> if (movedThreads.isNotEmpty() && movedThreads.contains(uid)) tryToAutoAdvance.postValue(Unit) } - refreshFoldersAsync( mailbox = mailbox, messagesFoldersIds = messages.getFoldersIds(exception = destinationFolder.id), @@ -339,7 +338,7 @@ class ActionsViewModel @Inject constructor( mailbox, currentFolderId, onlyPermanentlyDeleteMessages, - messagesToDelete + messagesToDelete, ) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index e9efbf1532..b46479fafb 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -79,7 +79,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetMultiSelectBinding by safeBinding() private val navigationArgs: MultiSelectBottomSheetDialogArgs by navArgs() - val mainViewModel: MainViewModel by activityViewModels() + private val mainViewModel: MainViewModel by activityViewModels() override val multiselectionViewModel: MultiselectionViewModel by activityViewModels() private val actionsViewModel: ActionsViewModel by activityViewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/UserToBlockBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/UserToBlockBottomSheetDialog.kt index 6d9aa0ebbd..47f6f4c117 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/UserToBlockBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/UserToBlockBottomSheetDialog.kt @@ -25,14 +25,12 @@ import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.infomaniak.core.legacy.utils.safeBinding import com.infomaniak.mail.databinding.BottomSheetUserToBlockBinding -import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel class UserToBlockBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetUserToBlockBinding by safeBinding() - val mainViewModel: MainViewModel by activityViewModels() override val multiselectionViewModel: MultiselectionViewModel by activityViewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt index 64669c82aa..45d5ffcca0 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt @@ -43,7 +43,7 @@ interface MultiSelectionHost : LifecycleOwner { threadsUids: Array, messagesUids: Array? = null, action: FolderPickerAction = FolderPickerAction.MOVE, - sourceFolderId: String? = null + sourceFolderId: String? = null, ): NavDirections fun directionsToMultiSelectBottomSheetDialog(isFromSearch: Boolean): NavDirections From 0103d9854c6d2cba57893a83ce4eb05af0eb0a81 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 13:26:49 +0200 Subject: [PATCH 39/52] fix: Make multiselectionviewmodel null safe --- .../multiselection/MultiselectionViewModel.kt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt index 540a3901be..395963aab6 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackMultiSelectionEvent +import com.infomaniak.mail.data.models.message.Message import com.infomaniak.mail.data.models.thread.Thread import dagger.hilt.android.lifecycle.HiltViewModel import io.realm.kotlin.notifications.ResultsChange @@ -33,18 +34,18 @@ import javax.inject.Inject class MultiselectionViewModel @Inject constructor( application: Application, ) : AndroidViewModel(application) { + private val _selectedThreads = mutableSetOf() val isMultiSelectOnLiveData = MutableLiveData(false) - inline var isMultiSelectOn - get() = isMultiSelectOnLiveData.value!! + var isMultiSelectOn: Boolean + get() = isMultiSelectOnLiveData.value ?: false set(value) { isMultiSelectOnLiveData.value = value } + val selectedThreadsLiveData = MutableLiveData(_selectedThreads) + val selectedThreads: MutableSet + get() = _selectedThreads - val selectedThreadsLiveData = MutableLiveData(mutableSetOf()) - inline val selectedThreads - get() = selectedThreadsLiveData.value!! - - inline val selectedMessages + val selectedMessages: List get() = selectedThreads.flatMap { thread -> thread.messages } fun isEverythingSelected(currentThreadCount: Int): Boolean { @@ -79,6 +80,6 @@ class MultiselectionViewModel @Inject constructor( } fun publishSelectedItems() { - selectedThreadsLiveData.value = selectedThreads + selectedThreadsLiveData.value = _selectedThreads } } From 136dbc260791377959938d82c718f3d9ffe812d6 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 13:27:25 +0200 Subject: [PATCH 40/52] fix: Show block multiple users dialog in search --- .../thread/actions/MultiSelectBottomSheetDialog.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index b46479fafb..c4c1aa2333 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -53,6 +53,7 @@ import com.infomaniak.mail.ui.main.folder.ThreadListMultiSelection.Companion.get import com.infomaniak.mail.ui.main.folder.ThreadListMultiSelection.Companion.getFavoriteIconAndShortText import com.infomaniak.mail.ui.main.folder.ThreadListMultiSelection.Companion.getReadIconAndShortText import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction +import com.infomaniak.mail.ui.main.search.SearchFragment import com.infomaniak.mail.ui.main.search.SearchFragmentDirections import com.infomaniak.mail.ui.main.search.SearchViewModel import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType @@ -219,11 +220,16 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { SentryLog.e(TAG, getString(R.string.sentryErrorPotentialUsersToBlockNull)) return } - + val substituteClassName = if (navigationArgs.isFromSearch) { + SearchFragment::class.java.name + } else { + ThreadListFragment::class.java.name + } + if (potentialUsersToBlock.count() > 1) { safelyNavigate( resId = R.id.userToBlockBottomSheetDialog, - substituteClassName = ThreadListFragment::class.java.name, + substituteClassName = substituteClassName, ) } else { potentialUsersToBlock.values.firstOrNull()?.let { message -> From 5b313f6b45366df39af6b529332039805aaf42d4 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 13:38:51 +0200 Subject: [PATCH 41/52] fix: Remove unnecessary mainviewmodel --- .../mail/ui/main/thread/actions/ReplyBottomSheetDialog.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ReplyBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ReplyBottomSheetDialog.kt index cf57f90205..340704d360 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ReplyBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ReplyBottomSheetDialog.kt @@ -30,17 +30,14 @@ import com.infomaniak.mail.MatomoMail.trackEvent import com.infomaniak.mail.R import com.infomaniak.mail.data.models.draft.Draft.DraftMode import com.infomaniak.mail.databinding.BottomSheetReplyBinding -import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.utils.extensions.safeNavigateToNewMessageActivity open class ReplyBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetReplyBinding by safeBinding() - val mainViewModel: MainViewModel? = null override val multiselectionViewModel: MultiselectionViewModel by activityViewModels() private val navigationArgs: ReplyBottomSheetDialogArgs by navArgs() - private val currentClassName: String by lazy { ReplyBottomSheetDialog::class.java.name } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { From d3f9e91cae715a235884339cd4bf0c2a6c6f6632 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 13:39:13 +0200 Subject: [PATCH 42/52] fix: Use action passed by parameters --- .../java/com/infomaniak/mail/ui/main/search/SearchFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index e0629ae4f9..6f15ed7832 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -155,7 +155,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { return SearchFragmentDirections.actionSearchFragmentToFolderPickerFragment( threadsUids = threadsUids, messagesUids = messagesUids, - action = FolderPickerAction.MOVE, + action = action, sourceFolderId = sourceFolderId, isFromSearch = true, ) From 93feeabba68925c38c2c0ef818274262a872ee99 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 14:03:11 +0200 Subject: [PATCH 43/52] fix: Fix share email in search --- .../mail/ui/main/search/SearchFragment.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 6f15ed7832..1b30755e3a 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -42,6 +42,7 @@ import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy import com.infomaniak.core.fragmentnavigation.safelyNavigate +import com.infomaniak.core.legacy.utils.SnackbarUtils.showSnackbar import com.infomaniak.core.legacy.utils.Utils import com.infomaniak.core.legacy.utils.hideKeyboard import com.infomaniak.core.legacy.utils.showKeyboard @@ -80,6 +81,7 @@ import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionB import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionHost import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.utils.FolderRoleUtils +import com.infomaniak.mail.utils.NetworkManager import com.infomaniak.mail.utils.Utils.Shortcuts import com.infomaniak.mail.utils.extensions.addStickyDateDecoration import com.infomaniak.mail.utils.extensions.applySideAndBottomSystemInsets @@ -90,6 +92,7 @@ import com.infomaniak.mail.utils.extensions.handleEditorSearchAction import com.infomaniak.mail.utils.extensions.safeArea import com.infomaniak.mail.utils.extensions.safelyAnimatedNavigation import com.infomaniak.mail.utils.extensions.setOnClearTextClickListener +import com.infomaniak.mail.utils.extensions.shareString import com.infomaniak.mail.utils.extensions.updateSwipeActionsUi import com.infomaniak.mail.utils.extensions.updateSwipeAvailability import dagger.hilt.android.AndroidEntryPoint @@ -243,6 +246,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { observeHistory() observeMultiSelect() observeSearchRefresh() + observeShareUrlResult() } private fun handleEdgeToEdge(): Unit = with(binding) { @@ -306,6 +310,20 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { } } + private fun observeShareUrlResult() { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + mainViewModel.shareThreadUrlResult.collect { url -> + if (url.isNullOrEmpty()) showErrorShareUrl() else requireContext().shareString(url) + } + } + } + } + + private fun showErrorShareUrl() { + showSnackbar(title = if (mainViewModel.hasNetwork) RCore.string.anErrorHasOccurred else RCore.string.noConnection) + } + private fun updateSwipeActionsAccordingToSettings() { unlockSwipeActionsIfSet() From ff6d9f5e815c1b6f54c354cc1240ebda84b21b2c Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 14:23:06 +0200 Subject: [PATCH 44/52] refactor: Remove unused folder id and import --- app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt | 1 - .../com/infomaniak/mail/ui/main/search/SearchFragment.kt | 1 - .../mail/ui/main/thread/actions/ActionsViewModel.kt | 1 - .../ui/main/thread/actions/MultiSelectBottomSheetDialog.kt | 5 +++-- .../java/com/infomaniak/mail/useCases/MessagesActions.kt | 6 ------ 5 files changed, 3 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt index 2bddd1fbfb..f4f27acd70 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -731,7 +731,6 @@ class MainViewModel @Inject constructor( threadsUids = threadsUids, messagesUids = messagesUids, mailbox = mailbox, - currentFolderId = currentFolderId, ) ?: run { snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) return@launch diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 1b30755e3a..b101a21642 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -81,7 +81,6 @@ import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionB import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiSelectionHost import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel import com.infomaniak.mail.utils.FolderRoleUtils -import com.infomaniak.mail.utils.NetworkManager import com.infomaniak.mail.utils.Utils.Shortcuts import com.infomaniak.mail.utils.extensions.addStickyDateDecoration import com.infomaniak.mail.utils.extensions.applySideAndBottomSystemInsets diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index 1952f3e006..3a6d319fcf 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -142,7 +142,6 @@ class ActionsViewModel @Inject constructor( ) { val result = messagesActions.toggleMessagesSpamStatus( messages = messages, - currentFolderId = currentFolderId, mailbox = mailbox, ) ?: run { snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index c4c1aa2333..0f567f7dd9 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -198,13 +198,14 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { SentryLog.e(TAG, getString(R.string.sentryErrorPhishingMessagesEmpty)) } + val currentFolderId = if (navigationArgs.isFromSearch) searchViewModel.filterFolder?.id else mainViewModel.currentFolderId descriptionDialog.show( title = getString(R.string.reportPhishingTitle), description = resources.getQuantityString(R.plurals.reportPhishingDescription, messages.count()), onPositiveButtonClicked = { actionsViewModel.reportPhishing( messages = messages, - currentFolderId = mainViewModel.currentFolderId, + currentFolderId = currentFolderId, mailbox = currentMailbox, ) }, @@ -225,7 +226,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { } else { ThreadListFragment::class.java.name } - + if (potentialUsersToBlock.count() > 1) { safelyNavigate( resId = R.id.userToBlockBottomSheetDialog, diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt index 101d1f3d4a..69e1cd9b65 100644 --- a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActions.kt @@ -146,10 +146,7 @@ class MessagesActions @Inject constructor( threadsUids: List, messagesUids: List? = null, mailbox: Mailbox, - currentFolderId: String?, ): MoveMessagesResult? { - if (currentFolderId == null) return null - val destinationFolder = folderController.getFolder(destinationFolderId) ?: return null var messagesToMove: List if (messagesUids != null) { @@ -167,11 +164,8 @@ class MessagesActions @Inject constructor( // Spam Region suspend fun toggleMessagesSpamStatus( messages: List, - currentFolderId: String?, mailbox: Mailbox, ): MoveMessagesResult? { - if (currentFolderId == null) return null - val messagesFolderRoles = folderRoleUtils.getActionFolderRoles(messages) val destinationFolderRole = if (messagesFolderRoles.contains(FolderRole.SPAM)) FolderRole.INBOX else FolderRole.SPAM val destinationFolder = folderController.getFolder(destinationFolderRole) ?: return null From 23ddf93098efc53683612d9ab89e61453cbbf88f Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 14:23:32 +0200 Subject: [PATCH 45/52] fix: Change threadlist binding type --- .../main/thread/actions/multiselection/MultiselectionBinding.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionBinding.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionBinding.kt index 8ba82b81cd..99b30ed265 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionBinding.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionBinding.kt @@ -22,7 +22,7 @@ interface MultiSelectionBinding { val multiselectToolbar: com.infomaniak.mail.databinding.ViewMultiselectionInfoToolbarBinding val toolbarLayout: android.view.View val toolbar: android.view.View - val threadsList: android.view.ViewGroup + val threadsList: com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView val newMessageFab: android.view.View? val unreadCountChip: android.view.View? } From d0247466cef9e24d7d6881addb1c8f5bcb463c75 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 14:24:07 +0200 Subject: [PATCH 46/52] fix: Notify search refresh even if the call didn't work, since the thread will have been locally moved out --- .../infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index 3a6d319fcf..c376d0fe2c 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -158,7 +158,6 @@ class ActionsViewModel @Inject constructor( currentFolderId = currentFolderId, threadsUids = movedThreads, ) - notifySearchRefresh() } if (displaySnackbar) showMoveSnackbar( @@ -175,6 +174,8 @@ class ActionsViewModel @Inject constructor( threadController.updateIsLocallyMovedOutStatus(threadsUids = movedThreads, hasBeenMovedOut = false) } } + + notifySearchRefresh() } } From 7940974eab36817f53c59e46139e4e6f7b2fc69b Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 15:02:13 +0200 Subject: [PATCH 47/52] fix: Archive action when they come from different folders --- .../java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt | 2 +- .../infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt index 4f7cd5b185..71570a9399 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchViewModel.kt @@ -218,7 +218,7 @@ class SearchViewModel @Inject constructor( private fun shouldShowContacts(withContacts: Boolean): Boolean { if (!withContacts) return false - + val hasQuery = currentSearchQuery.isNotBlank() val hasNoFilters = currentFilters.isEmpty() val notValidated = currentUiState != SearchUiState.VALIDATED diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index c376d0fe2c..429c2df5d0 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -455,7 +455,7 @@ class ActionsViewModel @Inject constructor( ) { if (messages.isEmpty()) return val roles = folderRoleUtils.getActionFolderRoles(messages) - val isFromArchive = roles.contains(FolderRole.ARCHIVE) + val isFromArchive = roles.all { it == FolderRole.ARCHIVE } val destinationFolderRole = if (isFromArchive) FolderRole.INBOX else FolderRole.ARCHIVE val destinationFolder = folderController.getFolder(destinationFolderRole) ?: return From d6a5b3daa9910bef804a753af70d00fcf37d17a1 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 15:04:07 +0200 Subject: [PATCH 48/52] fix: Refresh search with contacts when multiselection is closed --- .../mail/ui/main/search/SearchFragment.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index b101a21642..7d89df273c 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -303,6 +303,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { actionsViewModel.searchRefreshEvents.collect { + showRefreshLayout() searchViewModel.refreshSearch(withContacts = !multiselectionViewModel.isMultiSelectOn) } } @@ -393,7 +394,6 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { } multiselectToolbar.cancel.setOnClickListener { trackMultiSelectionEvent(MatomoName.Cancel) - searchViewModel.refreshSearch(withContacts = true) multiselectionViewModel.isMultiSelectOn = false } multiselectToolbar.selectAll.setOnClickListener { @@ -645,10 +645,16 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { } private fun observeMultiSelect() { + var wasMultiSelectOn = false multiselectionViewModel.isMultiSelectOnLiveData.observe(viewLifecycleOwner) { isMultiSelectOn -> - if (isMultiSelectOn) searchViewModel.contactsResults.value = emptyList() - val autoTransition = AutoTransition() - autoTransition.duration = TOOLBAR_FADE_DURATION + if (isMultiSelectOn) { + searchViewModel.contactsResults.value = emptyList() + } else if (wasMultiSelectOn) { + showRefreshLayout() + searchViewModel.refreshSearch(withContacts = true) + } + wasMultiSelectOn = isMultiSelectOn + val autoTransition = AutoTransition().setDuration(TOOLBAR_FADE_DURATION) TransitionManager.beginDelayedTransition(binding.horizontalScrollViewFilters, autoTransition) binding.horizontalScrollViewFilters.isGone = isMultiSelectOn } From 6547a52c7b7c6c65ab3e905b260261ea25b05daf Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 15:45:40 +0200 Subject: [PATCH 49/52] fix: Fix showing refresh laying after coming back to search --- .../com/infomaniak/mail/ui/main/search/SearchFragment.kt | 8 +++++++- .../mail/ui/main/thread/actions/ActionsViewModel.kt | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 7d89df273c..ab9622dd7b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -266,9 +266,11 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { override fun onResume() { super.onResume() updateSwipeActionsAccordingToSettings() + // If the user opens the reply dialog and comes backs to search, the refresh layout doesn't hide. That's why we need + // to call hideRefreshLayout here. + hideRefreshLayout() } - override fun onStop() { searchViewModel.cancelSearch() super.onStop() @@ -669,6 +671,10 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { binding.swipeRefreshLayout.isRefreshing = true } + private fun hideRefreshLayout() { + binding.swipeRefreshLayout.isRefreshing = false + } + enum class VisibilityMode { RECENT_SEARCHES, LOADING, diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index 429c2df5d0..0c2008847c 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -95,7 +95,7 @@ class ActionsViewModel @Inject constructor( private val _searchRefreshEvents = Channel(Channel.CONFLATED) val searchRefreshEvents = _searchRefreshEvents.receiveAsFlow() - private fun notifySearchRefresh() { + fun notifySearchRefresh() { _searchRefreshEvents.trySend(Unit) } //endregion From ee37200983a4cce0f2a0fc7c248db0e5ef71b26c Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 16:04:47 +0200 Subject: [PATCH 50/52] fix: Use correct folderRole when swiping and change files names --- .../java/com/infomaniak/mail/ui/main/search/SearchFragment.kt | 4 ++-- .../{MultiselectionBinding.kt => MultiSelectionBinding.kt} | 0 .../{MultiselectionHost.kt => MultiSelectionHost.kt} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/{MultiselectionBinding.kt => MultiSelectionBinding.kt} (100%) rename app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/{MultiselectionHost.kt => MultiSelectionHost.kt} (100%) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index ab9622dd7b..ea9602cd4c 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -267,7 +267,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { super.onResume() updateSwipeActionsAccordingToSettings() // If the user opens the reply dialog and comes backs to search, the refresh layout doesn't hide. That's why we need - // to call hideRefreshLayout here. + // to call hideRefreshLayout here. hideRefreshLayout() } @@ -332,7 +332,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { // Manually update disabled ui in case LocalSettings have changed when coming back from settings updateDisabledSwipeActionsUi( featureFlags = mainViewModel.featureFlagsLive.value, - folderRole = mainViewModel.currentFolderLive.value?.role, + folderRole = searchViewModel.filterFolder?.role ?: mainViewModel.currentFolderLive.value?.role, ) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionBinding.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiSelectionBinding.kt similarity index 100% rename from app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionBinding.kt rename to app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiSelectionBinding.kt diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiSelectionHost.kt similarity index 100% rename from app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionHost.kt rename to app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiSelectionHost.kt From 865595349dc1ba009102e6b688744755f796e45e Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 29 May 2026 16:18:16 +0200 Subject: [PATCH 51/52] refactor: Rename multiselection toolbar so it doesn't conflict with the fragment toolbar --- .../mail/ui/main/folder/ThreadListMultiSelection.kt | 4 ++-- .../java/com/infomaniak/mail/ui/main/search/SearchFragment.kt | 2 +- app/src/main/res/layout/view_multiselection_info_toolbar.xml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index 9ee95c5794..a4bdd96834 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -186,10 +186,10 @@ class ThreadListMultiSelection { private fun displaySelectionToolbar(isMultiSelectOn: Boolean) = with(host.multiSelectionBinding) { val autoTransition = AutoTransition() autoTransition.duration = TOOLBAR_FADE_DURATION - TransitionManager.beginDelayedTransition(multiselectToolbar.toolbar, autoTransition) + TransitionManager.beginDelayedTransition(multiselectToolbar.multiselectionInfoToolbar, autoTransition) toolbar.isGone = isMultiSelectOn - multiselectToolbar.toolbar.isVisible = isMultiSelectOn + multiselectToolbar.multiselectionInfoToolbar.isVisible = isMultiSelectOn } private fun lockDrawerAndSwipe(isMultiSelectOn: Boolean) = with(host) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index ea9602cd4c..041bd8d358 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -170,7 +170,7 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { get() = object : MultiSelectionBinding { override val quickActionBar get() = binding.quickActionBar override val multiselectToolbar get() = binding.multiselectToolbar - override val toolbarLayout get() = binding.multiselectToolbar.toolbar + override val toolbarLayout get() = binding.multiselectToolbar.multiselectionInfoToolbar override val toolbar get() = binding.toolbar override val threadsList get() = binding.mailRecyclerView override val newMessageFab get() = null diff --git a/app/src/main/res/layout/view_multiselection_info_toolbar.xml b/app/src/main/res/layout/view_multiselection_info_toolbar.xml index ec642679f2..5f4b145000 100644 --- a/app/src/main/res/layout/view_multiselection_info_toolbar.xml +++ b/app/src/main/res/layout/view_multiselection_info_toolbar.xml @@ -19,7 +19,7 @@ Date: Mon, 1 Jun 2026 14:10:33 +0200 Subject: [PATCH 52/52] fix: Fix crash on search because binding was null --- .../com/infomaniak/mail/ui/main/search/SearchFragment.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt index 041bd8d358..de3350172b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/search/SearchFragment.kt @@ -626,14 +626,17 @@ class SearchFragment : TwoPaneFragment(), MultiSelectionHost { } private fun observeSearchResults() = viewLifecycleOwner.lifecycleScope.launch { + val viewLifecycleScope = viewLifecycleOwner.lifecycleScope searchViewModel.allSearchResults.collectLatest { searchResults -> // Wait for any running swipe animation to finish before updating the list if (threadListViewModel.isRecoveringFinished.value == false) { threadListViewModel.isRecoveringFinished.asFlow().first { it } } - binding.mailRecyclerView.postOnAnimation { - threadListAdapter.updateListWithThreadListItems(searchResults, viewLifecycleOwner.lifecycleScope) + _binding?.mailRecyclerView?.postOnAnimation { + if (_binding != null) { + threadListAdapter.updateListWithThreadListItems(searchResults, viewLifecycleScope) + } } } }