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 e12baef41d4..c4a20cb45d0 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 d6acc1fa0c9..f4f27acd70a 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 @@ -746,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 @@ -819,22 +803,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 ae05f84ee47..e94aee3d338 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 @@ -20,19 +20,15 @@ package com.infomaniak.mail.ui.main.folder 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 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 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.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.utils.extensions.animatedNavigation @@ -40,181 +36,187 @@ 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 com.infomaniak.core.common.R as RCore object PerformSwipeActionManager { - /** * 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( + fun performSwipeAction( + host: SwipeActionHost, 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)) + if (!swipeAction.canDisplay(folderRole, host.mainViewModel.featureFlagsLive.value, host.localSettings)) { + host.showSwipeActionIncompatible() return true } trackEvent(MatomoCategory.SwipeActions, swipeAction.matomoName, TrackerAction.DRAG) - val shouldKeepItemBecauseOfAction = performSwipeAction( + val shouldKeepItemBecauseOfAction = performSwipeActionInternal( + host = host, swipeAction = swipeAction, folderRole = folderRole, thread = thread, position = position, isPermanentDeleteFolder = isPermanentDeleteFolder, + currentMailbox = currentMailbox ) - val shouldKeepItemBecauseOfNoConnection = !mainViewModel.hasNetwork + val shouldKeepItemBecauseOfNoConnection = !host.mainViewModel.hasNetwork return shouldKeepItemBecauseOfAction || shouldKeepItemBecauseOfNoConnection } - private fun ThreadListFragment.performSwipeAction( + private fun performSwipeActionInternal( + host: SwipeActionHost, swipeAction: SwipeAction, folderRole: FolderRole?, thread: Thread, position: Int, - isPermanentDeleteFolder: Boolean + isPermanentDeleteFolder: Boolean, + currentMailbox: Mailbox, ): 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 = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID - ), + 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, - mailbox = currentMailbox + shouldRead = !thread.isSeen, + currentFolderId = thread.folderId, + mailbox = currentMailbox, ) - mainViewModel.currentFilter.value != ThreadFilter.UNSEEN + true } SwipeAction.SPAM -> { - actionsViewModel.toggleThreadsSpamStatus( + host.actionsViewModel.toggleThreadsSpamStatus( threads = setOf(thread), - currentFolderId = mainViewModel.currentFolderId, - mailbox = currentMailbox + 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?, - currentMailBox: Mailbox + currentMailBox: Mailbox, ): Boolean { 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), - currentFolder = mainViewModel.currentFolder.value, - mailbox = currentMailBox + currentFolderId = thread.folderId, + mailbox = currentMailBox, ) } - return descriptionDialog.archiveWithConfirmationPopup( + return host.descriptionDialog.archiveWithConfirmationPopup( folderRole = folderRole, count = 1, displayLoader = false, onCancel = ::onCancel, - onPositiveButtonClicked = ::onSuccess + onPositiveButtonClicked = ::onSuccess, ) } - private fun ThreadListFragment.handleDeleteSwipe( + private fun handleDeleteSwipe( + host: SwipeActionHost, 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), // 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), - currentFolder = mainViewModel.currentFolder.value, - mailbox = currentMailBox + currentFolderId = thread.folderId, + mailbox = currentMailBox, ) } 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, @@ -224,6 +226,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/SwipeActionHost.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeActionHost.kt new file mode 100644 index 00000000000..d28a38c5593 --- /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 00000000000..ba6d4eea73c --- /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 49d2039c263..a0653190585 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,14 +39,17 @@ 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 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 @@ -58,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 @@ -90,8 +91,12 @@ 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 +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 @@ -102,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 @@ -115,6 +119,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.updateSwipeActionsUi +import com.infomaniak.mail.utils.extensions.updateSwipeAvailability import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -125,13 +131,14 @@ 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 private val navigationArgs: ThreadListFragmentArgs by navArgs() private val threadListViewModel: ThreadListViewModel by viewModels() + private val multiselectionViewModel: MultiselectionViewModel by activityViewModels() private val emojiReactionsViewModel: EmojiReactionsViewModel by viewModels() override val substituteClassName: String = javaClass.name @@ -148,13 +155,24 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { private var isFirstTimeRefreshingThreads = true @Inject - lateinit var descriptionDialog: DescriptionAlertDialog + override lateinit var descriptionDialog: DescriptionAlertDialog @Inject - lateinit var downloadThreadsStatusManager: DownloadThreadsStatusManager + override lateinit var folderRoleUtils: FolderRoleUtils + + override fun safeNavigation(directions: NavDirections) { + safelyNavigate(directions) + } + + override val multiSelectionLifecycleOwner: LifecycleOwner + get() = viewLifecycleOwner + + override fun disableSwipeDirection(direction: DirectionFlag) { + binding.threadsList.disableSwipeDirection(direction) + } @Inject - lateinit var folderRoleUtils: FolderRoleUtils + lateinit var downloadThreadsStatusManager: DownloadThreadsStatusManager @Inject lateinit var inAppUpdateManager: InAppUpdateManager @@ -195,9 +213,14 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { threadListMultiSelection.initMultiSelection( mainViewModel = mainViewModel, actionsViewModel = actionsViewModel, - threadListFragment = this, + multiselectionViewModel = multiselectionViewModel, + activity = (requireActivity() as MainActivity), + host = this, + folderRoleUtils = folderRoleUtils, unlockSwipeActionsIfSet = ::unlockSwipeActionsIfSet, localSettings = localSettings, + searchViewModel = null, + isFromSearch = false, ) observeNetworkStatus() @@ -235,6 +258,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,16 +366,40 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { _binding = null } - private fun unlockSwipeActionsIfSet() = with(binding.threadsList) { - val isMultiSelectClosed = mainViewModel.isMultiSelectOn.not() + override fun unlockSwipeActionsIfSet() { + binding.threadsList.updateSwipeAvailability(localSettings, multiselectionViewModel.isMultiSelectOn) + } - val isLeftSet = localSettings.swipeLeft != SwipeAction.NONE - val isLeftEnabled = isLeftSet && isMultiSelectClosed - if (isLeftEnabled) enableSwipeDirection(DirectionFlag.LEFT) else disableSwipeDirection(DirectionFlag.LEFT) + override fun directionToThreadActionsBottomSheetDialog( + threadUid: String, + shouldLoadDistantResources: Boolean, + shouldCloseMultiSelection: Boolean, + isFromSearch: Boolean, + ): NavDirections { + return ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( + threadUid, + shouldLoadDistantResources, + shouldCloseMultiSelection, + isFromSearch + ) + } - val isRightSet = localSettings.swipeRight != SwipeAction.NONE - val isRightEnabled = isRightSet && isMultiSelectClosed - if (isRightEnabled) enableSwipeDirection(DirectionFlag.RIGHT) else disableSwipeDirection(DirectionFlag.RIGHT) + 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 = threadsUids, + messagesUids = messagesUids, + action = action, + sourceFolderId = sourceFolderId, + ) } private fun setupDensityDependentUi() = with(binding) { @@ -401,9 +459,9 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { 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 }, ) @@ -422,26 +480,7 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { } 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) { @@ -451,12 +490,12 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { (requireActivity() as MainActivity).openDrawerLayout() } - cancel.setOnClickListener { + multiselectToolbar.cancel.setOnClickListener { trackMultiSelectionEvent(MatomoName.Cancel) - mainViewModel.isMultiSelectOn = false + multiselectionViewModel.isMultiSelectOn = false } - selectAll.setOnClickListener { - mainViewModel.selectOrUnselectAll() + multiselectToolbar.selectAll.setOnClickListener { + multiselectionViewModel.selectOrUnselectAll(mainViewModel.currentThreadsLive) threadListAdapter.updateSelection() } @@ -499,36 +538,12 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { } } - 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) = @@ -573,7 +588,45 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { 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 = SwipeActionHostFactory.create( + fragment = this@ThreadListFragment, + mainViewModel = mainViewModel, + actionsViewModel = actionsViewModel, + localSettings = localSettings, + threadListAdapter = threadListAdapter, + descriptionDialog = descriptionDialog, + showSwipeActionIncompatible = { + snackbarManager.setValue(getString(R.string.snackbarSwipeActionIncompatible)) + }, + directionsToMove = { threadUid, sourceFolderId -> + ThreadListFragmentDirections.actionThreadListFragmentToFolderPickerFragment( + threadsUids = arrayOf(threadUid), + action = FolderPickerAction.MOVE, + sourceFolderId = sourceFolderId, + isFromSearch = false, + ) + }, + directionsToQuickActions = { threadUid -> + ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( + threadUid = threadUid, + shouldLoadDistantResources = false, + shouldCloseMultiSelection = false, + isFromSearch = false, + ) + }, + navigateToSnoozeBottomSheet = { snoozeScheduleType, snoozeEndDate -> + navigateToSnoozeBottomSheet(snoozeScheduleType, snoozeEndDate) + }, + ) + + performSwipeAction(host, swipeAction, thread, position, isPermanentDeleteFolder, currentMailbox) } private fun extendCollapseFab(scrollDirection: ScrollDirection) = with(binding) { @@ -824,7 +877,7 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { unreadCount, formatUnreadCount(unreadCount) ) - isGone = unreadCount == 0 || mainViewModel.isMultiSelectOn + isGone = unreadCount == 0 || multiselectionViewModel.isMultiSelectOn } } @@ -834,13 +887,13 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { 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 31bf5cd2111..a4bdd968343 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 @@ -22,7 +22,6 @@ import android.transition.TransitionManager import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope -import com.infomaniak.core.fragmentnavigation.safelyNavigate import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation.DirectionFlag import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackMultiSelectActionEvent @@ -33,7 +32,11 @@ 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 @@ -42,37 +45,56 @@ import kotlinx.coroutines.launch class ThreadListMultiSelection { lateinit var mainViewModel: MainViewModel + lateinit var multiselectionViewModel: MultiselectionViewModel lateinit var actionsViewModel: ActionsViewModel - private lateinit var threadListFragment: ThreadListFragment + 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 fun initMultiSelection( mainViewModel: MainViewModel, + multiselectionViewModel: MultiselectionViewModel, actionsViewModel: ActionsViewModel, - threadListFragment: ThreadListFragment, + activity: MainActivity, + host: MultiSelectionHost, + folderRoleUtils: FolderRoleUtils, unlockSwipeActionsIfSet: () -> Unit, localSettings: LocalSettings, + isFromSearch: Boolean, + searchViewModel: SearchViewModel?, ) { this.mainViewModel = mainViewModel + this.multiselectionViewModel = multiselectionViewModel this.actionsViewModel = actionsViewModel - this.threadListFragment = threadListFragment + this.mainActivity = activity + this.host = host + this.folderRoleUtils = folderRoleUtils this.unlockSwipeActionsIfSet = unlockSwipeActionsIfSet this.localSettings = localSettings + this.isFromSearch = isFromSearch - setupMultiSelectionActions() + if (isFromSearch) { + requireNotNull(searchViewModel) { "searchViewModel is required when isFromSearch is true" } + this.searchViewModel = searchViewModel + } + setupMultiSelectionActions() observerMultiSelection() } - private fun setupMultiSelectionActions() = with(mainViewModel) { - threadListFragment.binding.quickActionBar.setOnItemClickListener { menuId -> + 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 + val currentFolderId = if (isFromSearch) searchViewModel.filterFolder?.id else mainViewModel.currentFolderId when (menuId) { R.id.quickActionUnread -> { @@ -85,15 +107,15 @@ 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) actionsViewModel.archiveThreads( threads = selectedThreads.toList(), - currentFolder = currentFolder.value, + currentFolderId = currentFolderId, mailbox = currentMailBox, ) isMultiSelectOn = false @@ -108,51 +130,51 @@ 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), - currentFolderRole = currentFolder.value?.role, + host.descriptionDialog.deleteWithConfirmationPopup( + messagesFolderRoles = host.folderRoleUtils.getActionFolderRoles(allMessages), + currentFolderRole = mainViewModel.currentFolder.value?.role, count = selectedThreadsCount, ) { trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) - actionsViewModel.deleteThreads(selectedThreads.toList(), currentFolder.value, currentMailBox) + actionsViewModel.deleteThreads(selectedThreads.toList(), currentFolderId, currentMailBox) isMultiSelectOn = false } } R.id.quickActionMenu -> { trackMultiSelectActionEvent(MatomoName.OpenBottomSheet, selectedThreadsCount) val direction = if (selectedThreadsCount == 1) { - isMultiSelectOn = false - ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( + host.directionToThreadActionsBottomSheetDialog( threadUid = selectedThreadsUids.single(), shouldLoadDistantResources = false, shouldCloseMultiSelection = true, + isFromSearch = isFromSearch, ) } else { - ThreadListFragmentDirections.actionThreadListFragmentToMultiSelectBottomSheetDialog() + host.directionsToMultiSelectBottomSheetDialog(isFromSearch) } - threadListFragment.safelyNavigate(direction) + host.safeNavigation(direction) } } } } - private fun observerMultiSelection() = with(threadListFragment) { - mainViewModel.isMultiSelectOnLiveData.observe(viewLifecycleOwner) { isMultiSelectOn -> + private fun observerMultiSelection() = with(host) { + multiselectionViewModel.isMultiSelectOnLiveData.observe(multiSelectionLifecycleOwner) { isMultiSelectOn -> threadListAdapter.updateSelection() - if (localSettings.threadDensity != ThreadDensity.LARGE) TransitionManager.beginDelayedTransition(binding.threadsList) - if (!isMultiSelectOn) mainViewModel.selectedThreads.clear() + if (localSettings.threadDensity != ThreadDensity.LARGE) TransitionManager.beginDelayedTransition(host.multiSelectionBinding.threadsList) + if (!isMultiSelectOn) multiselectionViewModel.selectedThreads.clear() displaySelectionToolbar(isMultiSelectOn) lockDrawerAndSwipe(isMultiSelectOn) - hideUnreadChip(isMultiSelectOn) + if (multiSelectionBinding.unreadCountChip != null) hideUnreadChip(isMultiSelectOn) displayMultiSelectActions(isMultiSelectOn) } - mainViewModel.selectedThreadsLiveData.observe(viewLifecycleOwner) { selectedThreads -> + multiselectionViewModel.selectedThreadsLiveData.observe(multiSelectionLifecycleOwner) { selectedThreads -> if (selectedThreads.isEmpty()) { - mainViewModel.isMultiSelectOn = false + multiselectionViewModel.isMultiSelectOn = false } else { updateSelectedCount(selectedThreads) updateSelectAllLabel() @@ -161,19 +183,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.multiselectionInfoToolbar, autoTransition) toolbar.isGone = isMultiSelectOn - toolbarSelection.isVisible = isMultiSelectOn + multiselectToolbar.multiselectionInfoToolbar.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 +206,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 @@ -202,8 +224,13 @@ class ThreadListMultiSelection { } private fun updateSelectAllLabel() { - val selectAllLabel = if (mainViewModel.isEverythingSelected) R.string.buttonUnselectAll else R.string.buttonSelectAll - threadListFragment.binding.selectAll.setText(selectAllLabel) + 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) } private fun updateMultiSelectActionsStatus(selectedThreads: Set) { @@ -212,7 +239,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 +248,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/ThreadSwipeListenerFactory.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadSwipeListenerFactory.kt new file mode 100644 index 00000000000..583d16e42a9 --- /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/folder/TwoPaneFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt index f3612b2298b..dedb4c3041f 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/folderPicker/FolderPickerFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt index def1b5e7d61..4db6f6c5d3e 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,22 +175,24 @@ class FolderPickerFragment : Fragment() { snackbarManager.postValue(getString(RCore.string.anErrorHasOccurred)) return@with } - if (messagesUids != null) { 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 + } } 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 fae78eb8c59..de3350172bf 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 @@ -29,59 +31,158 @@ import androidx.core.view.updatePaddingRelative import androidx.core.widget.doOnTextChanged 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 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.setMargins -import com.infomaniak.core.legacy.utils.setPagination 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.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.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.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 +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.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 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 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 @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 + private val multiselectionViewModel: MultiselectionViewModel by activityViewModels() + + @Inject + override lateinit var folderRoleUtils: FolderRoleUtils + + @Inject + override lateinit var descriptionDialog: DescriptionAlertDialog + + override fun safeNavigation(directions: NavDirections) { + safelyNavigate(directions) + } + + override val multiSelectionLifecycleOwner: LifecycleOwner + get() = viewLifecycleOwner + + override fun disableSwipeDirection(direction: DirectionFlag) { + binding.mailRecyclerView.disableSwipeDirection(direction) + } + + override fun unlockSwipeActionsIfSet() { + binding.mailRecyclerView.updateSwipeAvailability(localSettings, multiselectionViewModel.isMultiSelectOn) + } + + override fun directionToThreadActionsBottomSheetDialog( + threadUid: String, + shouldLoadDistantResources: Boolean, + shouldCloseMultiSelection: Boolean, + isFromSearch: Boolean, + ): NavDirections { + return SearchFragmentDirections.actionSearchFragmentToThreadActionsBottomSheetDialog( + threadUid, + shouldLoadDistantResources, + shouldCloseMultiSelection, + isFromSearch, + ) + } + + 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 = threadsUids, + messagesUids = messagesUids, + action = action, + sourceFolderId = sourceFolderId, + isFromSearch = true, + ) + } + + @Inject + lateinit var snackbarManager: SnackbarManager + + 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.multiselectionInfoToolbar + 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() + 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 { RecentSearchAdapter( searchQueries = localSettings.recentSearches.toMutableList(), @@ -119,6 +220,19 @@ class SearchFragment : TwoPaneFragment() { setupAdapter() setupListeners() + threadListMultiSelection.initMultiSelection( + mainViewModel = mainViewModel, + actionsViewModel = actionsViewModel, + multiselectionViewModel = multiselectionViewModel, + host = this, + folderRoleUtils = folderRoleUtils, + activity = (requireActivity() as MainActivity), + unlockSwipeActionsIfSet = ::unlockSwipeActionsIfSet, + localSettings = localSettings, + searchViewModel = searchViewModel, + isFromSearch = true, + ) + setAllFoldersButtonListener() setAttachmentsUi() setMutuallyExclusiveChipGroupUi() @@ -129,25 +243,34 @@ class SearchFragment : TwoPaneFragment() { observeVisibilityModeUpdates() observeSearchResults() observeHistory() + observeMultiSelect() + observeSearchRefresh() + observeShareUrlResult() } 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() + // 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() @@ -178,13 +301,52 @@ class SearchFragment : TwoPaneFragment() { super.handleOnBackPressed() } + private fun observeSearchRefresh() { + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + actionsViewModel.searchRefreshEvents.collect { + showRefreshLayout() + searchViewModel.refreshSearch(withContacts = !multiselectionViewModel.isMultiSelectOn) + } + } + } + } + + 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() + + // Manually update disabled ui in case LocalSettings have changed when coming back from settings + updateDisabledSwipeActionsUi( + featureFlags = mainViewModel.featureFlagsLive.value, + folderRole = searchViewModel.filterFolder?.role ?: mainViewModel.currentFolderLive.value?.role, + ) + } + + private fun updateDisabledSwipeActionsUi(featureFlags: FeatureFlagSet?, folderRole: FolderRole?) { + binding.mailRecyclerView.updateSwipeActionsUi(localSettings, featureFlags, folderRole) + } + 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) { @@ -208,8 +370,6 @@ class SearchFragment : TwoPaneFragment() { 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 { @@ -217,6 +377,12 @@ class SearchFragment : TwoPaneFragment() { setSelection(emailWithQuotes.length) } } + override val getFeatureFlags: () -> FeatureFlagSet? = { mainViewModel.featureFlagsLive.value } + }, + multiSelection = object : MultiSelectionListener { + override var isEnabled by multiselectionViewModel::isMultiSelectOn + override val selectedItems by multiselectionViewModel::selectedThreads + override val publishSelectedItems = multiselectionViewModel::publishSelectedItems }, ) @@ -228,7 +394,79 @@ class SearchFragment : TwoPaneFragment() { searchViewModel.clearSearchState() findNavController().popBackStack() } - swipeRefreshLayout.setOnRefreshListener { searchViewModel.refreshSearch() } + multiselectToolbar.cancel.setOnClickListener { + trackMultiSelectionEvent(MatomoName.Cancel) + multiselectionViewModel.isMultiSelectOn = false + } + multiselectToolbar.selectAll.setOnClickListener { + lifecycleScope.launch { + multiselectionViewModel.selectOrUnselectAllSearchItems(searchViewModel.threadsSearchResults) + threadListAdapter.updateSelection() + } + } + mailRecyclerView.swipeListener = ThreadSwipeListenerFactory.create( + localSettings = localSettings, + threadListAdapter = threadListAdapter, + onRecoveringStarted = { threadListViewModel.isRecoveringFinished.value = false }, + performSwipeActionOnThread = ::performSwipeActionOnThread, + ) + + swipeRefreshLayout.setOnRefreshListener { + searchViewModel.refreshSearch(withContacts = !multiselectionViewModel.isMultiSelectOn) + } + } + + /** + * 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 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 = SwipeActionHostFactory.create( + fragment = this@SearchFragment, + mainViewModel = mainViewModel, + actionsViewModel = actionsViewModel, + localSettings = localSettings, + threadListAdapter = threadListAdapter, + descriptionDialog = descriptionDialog, + showSwipeActionIncompatible = { + snackbarManager.setValue(getString(R.string.snackbarSwipeActionIncompatible)) + }, + directionsToMove = { threadUid, sourceFolderId -> + SearchFragmentDirections.actionSearchFragmentToFolderPickerFragment( + threadsUids = arrayOf(threadUid), + action = FolderPickerAction.MOVE, + sourceFolderId = sourceFolderId, + isFromSearch = true, + ) + }, + directionsToQuickActions = { threadUid -> + SearchFragmentDirections.actionSearchFragmentToThreadActionsBottomSheetDialog( + threadUid = threadUid, + shouldLoadDistantResources = false, + shouldCloseMultiSelection = false, + isFromSearch = true, + ) + }, + navigateToSnoozeBottomSheet = { snoozeScheduleType, snoozeEndDate -> + navigateToSnoozeBottomSheet(snoozeScheduleType, snoozeEndDate) + }, + ) + + performSwipeAction(host, swipeAction, thread, position, isPermanentDeleteFolder, currentMailbox) } private fun setAllFoldersButtonListener() { @@ -245,10 +483,11 @@ class SearchFragment : TwoPaneFragment() { } private fun selectCurrentFolder() { - val sourceFolder = mainViewModel.currentFolder.value - if (!searchViewModel.isAllFoldersSelected && searchViewModel.filterFolder == null && sourceFolder?.role != Folder.FolderRole.INBOX) { - searchViewModel.selectFolder(sourceFolder) + val currentFolder = mainViewModel.currentFolder.value + if (!searchViewModel.isAllFoldersSelected && searchViewModel.filterFolder == null && currentFolder?.role != FolderRole.INBOX) { + searchViewModel.selectFolder(currentFolder) } + updateAllFoldersButtonUi() trackSearchEvent(ThreadFilter.FOLDER.matomoName, true) } @@ -306,11 +545,6 @@ class SearchFragment : TwoPaneFragment() { disableDragDirection(DirectionFlag.UP) disableDragDirection(DirectionFlag.DOWN) - disableDragDirection(DirectionFlag.LEFT) - disableDragDirection(DirectionFlag.RIGHT) - - disableSwipeDirection(DirectionFlag.LEFT) - disableSwipeDirection(DirectionFlag.RIGHT) addStickyDateDecoration(threadListAdapter, localSettings.threadDensity) setPagination() @@ -392,8 +626,18 @@ class SearchFragment : TwoPaneFragment() { } private fun observeSearchResults() = viewLifecycleOwner.lifecycleScope.launch { + val viewLifecycleScope = viewLifecycleOwner.lifecycleScope 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 { + if (_binding != null) { + threadListAdapter.updateListWithThreadListItems(searchResults, viewLifecycleScope) + } + } } } @@ -405,6 +649,22 @@ class SearchFragment : TwoPaneFragment() { } } + private fun observeMultiSelect() { + var wasMultiSelectOn = false + multiselectionViewModel.isMultiSelectOnLiveData.observe(viewLifecycleOwner) { isMultiSelectOn -> + 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 + } + } + private fun updateHistoryEmptyStateVisibility(isThereHistory: Boolean) = with(binding) { recentSearches.isVisible = isThereHistory noHistory.isGone = isThereHistory @@ -414,6 +674,10 @@ class SearchFragment : TwoPaneFragment() { binding.swipeRefreshLayout.isRefreshing = true } + private fun hideRefreshLayout() { + binding.swipeRefreshLayout.isRefreshing = false + } + enum class VisibilityMode { RECENT_SEARCHES, LOADING, @@ -423,5 +687,6 @@ class SearchFragment : TwoPaneFragment() { 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 d5b09a48ee8..71570a93994 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 @@ -141,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) { @@ -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 @@ -330,6 +332,7 @@ class SearchViewModel @Inject constructor( filters: Set = currentFilters, folder: Folder? = filterFolder, shouldGetNextPage: Boolean = false, + withContacts: Boolean = true, ) = withContext(ioCoroutineContext) { cancelSearch() @@ -337,7 +340,7 @@ class SearchViewModel @Inject constructor( delay(SEARCH_DEBOUNCE_DURATION) ensureActive() - val showContacts = shouldShowContacts() && + val showContacts = shouldShowContacts(withContacts) && query.isNotBlank() && !query.contains("\"") && !isLengthTooShort(query) 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 a7d540b02db..a1eeeef6ddf 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,9 @@ 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() + + 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/ThreadFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt index 43d9542bc6d..83ab27397e7 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 @@ -929,22 +930,22 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { val thread = threadViewModel.threadLive.value ?: return@archiveWithConfirmationPopup actionsViewModel.archiveThreads( threads = listOf(thread), - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = thread.folderId, 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, + currentFolderId = thread.folderId, mailbox = mainViewModel.currentMailbox.value!!, ) @@ -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/ActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsBottomSheetDialog.kt index 23357d29c3b..b837a7df3b6 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/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index fa1f0a5fabd..0c2008847ce 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.CONFLATED) + val searchRefreshEvents = _searchRefreshEvents.receiveAsFlow() + + fun notifySearchRefresh() { + _searchRefreshEvents.trySend(Unit) + } + //endregion + //region AutoAdvance fun updateCurrentThreadPosition(currentPosition: Int, currentUid: String) { currentThread = currentPosition to currentUid @@ -131,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)) @@ -164,6 +174,8 @@ class ActionsViewModel @Inject constructor( threadController.updateIsLocallyMovedOutStatus(threadsUids = movedThreads, hasBeenMovedOut = false) } } + + notifySearchRefresh() } } @@ -211,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 } @@ -224,7 +231,6 @@ class ActionsViewModel @Inject constructor( calculateCurrentThreadPosition.postValue(Unit) val result = messagesActions.moveMessagesTo( - currentFolder = currentFolder, destinationFolder = destinationFolder, mailbox = mailbox, messages = messages, @@ -249,6 +255,8 @@ class ActionsViewModel @Inject constructor( threadController.updateIsLocallyMovedOutStatus(movedThreads, hasBeenMovedOut = false) } } + + notifySearchRefresh() } showMoveSnackbar( @@ -294,29 +302,27 @@ 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 - val permanentlyDeleteMessages = messagesToDelete.filter { message -> isPermanentDeleteFolder(role = folderRoleUtils.getActionFolderRole(message)) } @@ -328,11 +334,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 = permanentlyDeleteMessages, - mailbox = mailbox, - currentFolder = currentFolder, - shouldAutoAdvanceAndRefresh = onlyPermanentlyDeleteMessages, - messagesToDelete = messagesToDelete + permanentlyDeleteMessages, + mailbox, + currentFolderId, + onlyPermanentlyDeleteMessages, + messagesToDelete, ) } @@ -347,7 +353,7 @@ class ActionsViewModel @Inject constructor( moveMessagesTo( destinationFolderId = destinationFolder.id, messagesUids = deleteMessages.getUids(), - currentFolderId = currentFolder.id, + currentFolderId = currentFolderId, mailbox = mailbox, ) } @@ -356,7 +362,7 @@ class ActionsViewModel @Inject constructor( private suspend fun handlePermanentlyDeleteMessages( permanentlyDeleteMessages: List, mailbox: Mailbox, - currentFolder: Folder, + currentFolderId: String?, shouldAutoAdvanceAndRefresh: Boolean, messagesToDelete: List ) { @@ -364,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 @@ -380,9 +385,10 @@ class ActionsViewModel @Inject constructor( refreshFoldersAsync( mailbox = mailbox, messagesFoldersIds = messagesToDelete.getFoldersIds(), - currentFolderId = currentFolder.id, + currentFolderId = currentFolderId, threadsUids = uidsToMove, ) + notifySearchRefresh() val numberOfImpactedThreads = uidsToMove.distinct().count() showDeleteSnackbar( apiResponses = apiResponses, @@ -426,33 +432,34 @@ 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) { - handleArchiveMessages(messages, 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 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 - moveMessagesTo(destinationFolder.id, messages.getUids(), currentFolder?.id, mailbox) + moveMessagesTo(destinationFolder.id, messages.getUids(), currentFolderId, mailbox) } //region Seen @@ -470,6 +477,7 @@ class ActionsViewModel @Inject constructor( messagesFoldersIds = result.messages.getFoldersIds(), currentFolderId = currentFolderId, ) + notifySearchRefresh() } } @@ -486,9 +494,9 @@ class ActionsViewModel @Inject constructor( messagesFoldersIds = messages.getFoldersIds(), currentFolderId = currentFolderId, ) + notifySearchRefresh() } } - //endregion //region Favorite @@ -503,6 +511,7 @@ class ActionsViewModel @Inject constructor( mailbox = mailbox, messagesFoldersIds = result.messages.getFoldersIds(), ) + notifySearchRefresh() } } @@ -517,6 +526,7 @@ class ActionsViewModel @Inject constructor( mailbox = mailbox, messagesFoldersIds = messages.getFoldersIds(), ) + notifySearchRefresh() } } //endregion @@ -524,7 +534,7 @@ class ActionsViewModel @Inject constructor( //region Phishing fun reportPhishing( messages: List, - currentFolder: Folder?, + currentFolderId: String?, mailbox: Mailbox, ) { viewModelScope.launch(ioCoroutineContext) { @@ -534,7 +544,7 @@ class ActionsViewModel @Inject constructor( onReportSuccess = { toggleMessagesSpamStatus( messages = messages, - currentFolderId = currentFolder?.id, + currentFolderId = currentFolderId, mailbox = mailbox, displaySnackbar = false, ) @@ -544,6 +554,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 -> { @@ -582,6 +593,7 @@ class ActionsViewModel @Inject constructor( if (result is MessagesActions.SnoozeResult.Success) { refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(FolderRole.SNOOZED))) + notifySearchRefresh() } val message = when (result) { @@ -605,6 +617,7 @@ class ActionsViewModel @Inject constructor( if (result is BatchSnoozeResult.Success) { refreshFoldersAsync(mailbox, result.impactedFolders) + notifySearchRefresh() } val message = when (result) { @@ -630,6 +643,7 @@ class ActionsViewModel @Inject constructor( if (result is BatchSnoozeResult.Success) { refreshFoldersAsync(mailbox, result.impactedFolders) + notifySearchRefresh() } val message = when (result) { @@ -667,6 +681,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())) } @@ -689,6 +704,7 @@ class ActionsViewModel @Inject constructor( } refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(draftFolder.id))) + notifySearchRefresh() onSuccess() } else { snackbarManager.postValue(title = appContext.getString(apiResponse.translateError())) @@ -705,6 +721,7 @@ class ActionsViewModel @Inject constructor( } refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(scheduledDraftsFolder.id))) + notifySearchRefresh() } showUnscheduledDraftSnackbar(apiResponse, openFolder) @@ -741,6 +758,7 @@ class ActionsViewModel @Inject constructor( } refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(draftFolder.id))) + notifySearchRefresh() } showDeletedDraftSnackbar(apiResponse) @@ -764,6 +782,7 @@ class ActionsViewModel @Inject constructor( messagesFoldersIds = foldersIds, destinationFolderId = destinationFolderId, ) + notifySearchRefresh() } } 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 05cae1db6aa..054b22883d7 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 0dd491d81a3..8057003cba1 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 @@ -41,10 +40,12 @@ 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 + protected abstract val substituteClassName: String + private var onClickListener: OnActionClick = object : OnActionClick { @@ -100,7 +101,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 a13e8400de9..a37a9f61150 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,19 +32,21 @@ 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 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 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,12 +66,22 @@ 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() 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 @@ -182,7 +194,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetMessageActionsEvent(MatomoName.Delete) actionsViewModel.deleteMessages( messages = listOf(message), - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = message.folderId, mailbox = mainViewModel.currentMailbox.value!!, ) } @@ -195,7 +207,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetMessageActionsEvent(MatomoName.Archive, message.folder.role == FolderRole.ARCHIVE) actionsViewModel.archiveMessages( messages = listOf(message), - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = message.folderId, mailbox = mainViewModel.currentMailbox.value!!, ) } @@ -205,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() @@ -221,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(), ) } @@ -250,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!!, ) } @@ -263,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 f0d803fcc14..0f567f7dd97 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 @@ -39,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 @@ -51,14 +53,19 @@ 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 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 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 @@ -72,9 +79,12 @@ import com.infomaniak.core.common.R as RCore class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetMultiSelectBinding by safeBinding() - override val mainViewModel: MainViewModel by activityViewModels() + private val navigationArgs: MultiSelectBottomSheetDialogArgs by navArgs() + private 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 @@ -95,7 +105,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`. @@ -114,36 +124,33 @@ 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() 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 { @@ -156,51 +163,9 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { isMultiSelectOn = false } - binding.phishing.setOnClickListener { - 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, - currentFolder = mainViewModel.currentFolder.value, - 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 - } + binding.phishing.setOnClickListener { handlePhishing(threadsCount, currentMailbox) } - 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) @@ -209,8 +174,6 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { mailbox = currentMailbox, shouldFavorite = shouldFavorite, ) - - isMultiSelectOn = false } binding.saveKDrive.setClosingOnClickListener(shouldCloseMultiSelection = true) { @@ -219,10 +182,64 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { messageUids = threads.flatMap { it.messages }.map { it.uid }, currentClassName = MultiSelectBottomSheetDialog::class.java.name, ) - isMultiSelectOn = false } } + 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)) + } + + 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 = 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 + } + val substituteClassName = if (navigationArgs.isFromSearch) { + SearchFragment::class.java.name + } else { + ThreadListFragment::class.java.name + } + + if (potentialUsersToBlock.count() > 1) { + safelyNavigate( + resId = R.id.userToBlockBottomSheetDialog, + substituteClassName = substituteClassName, + ) + } else { + potentialUsersToBlock.values.firstOrNull()?.let { message -> + junkMessagesViewModel.messageOfUserToBlock.value = message + } + } + multiselectionViewModel.isMultiSelectOn = false + } + private fun setupMainActions( threads: Set, threadsUids: List, @@ -230,7 +247,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") @@ -252,6 +269,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { currentFolderId = currentFolderId, mailbox = currentMailbox, ) + multiselectionViewModel.isMultiSelectOn = false } R.id.actionArchive -> { descriptionDialog.archiveWithConfirmationPopup( @@ -261,9 +279,10 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { trackMultiSelectActionEvent(MatomoName.Archive, threadsCount, isFromBottomSheet = true) actionsViewModel.archiveThreads( threads = threads.toList(), - currentFolder = currentFolder, + currentFolderId = currentFolderId, mailbox = currentMailbox, ) + multiselectionViewModel.isMultiSelectOn = false } } R.id.actionDelete -> { @@ -275,9 +294,10 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { trackMultiSelectActionEvent(MatomoName.Delete, threadsCount, isFromBottomSheet = true) actionsViewModel.deleteThreads( threads = threads.toList(), - currentFolder = currentFolder, + currentFolderId = currentFolderId, mailbox = currentMailbox, ) + multiselectionViewModel.isMultiSelectOn = false } } } @@ -289,15 +309,35 @@ 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 messagesUids = multiselectionViewModel.selectedMessages.getUids().toTypedArray() + multiselectionViewModel.isMultiSelectOn = false + val navController = findNavController() + + if (navigationArgs.isFromSearch) { + navController.animatedNavigation( + directions = SearchFragmentDirections.actionSearchFragmentToFolderPickerFragment( + threadsUids = threadsUids.toTypedArray(), + messagesUids = messagesUids, + action = FolderPickerAction.MOVE, + sourceFolderId = searchViewModel.filterFolder?.id, + isFromSearch = true, + ) + ) + } else { navController.animatedNavigation( directions = ThreadListFragmentDirections.actionThreadListFragmentToFolderPickerFragment( threadsUids = threadsUids.toTypedArray(), + messagesUids = messagesUids, action = FolderPickerAction.MOVE, sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID, ), @@ -332,7 +372,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 @@ -342,7 +382,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/ReplyBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ReplyBottomSheetDialog.kt index 9d86cb6313c..340704d360c 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 @@ -29,15 +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() - override 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 { 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 8a8afebcd84..c1171250504 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 @@ -48,8 +47,10 @@ 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.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,13 +70,13 @@ 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() 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 @@ -112,8 +113,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)) @@ -123,6 +126,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) @@ -197,7 +208,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetThreadActionsEvent(MatomoName.Delete) actionsViewModel.deleteThreads( threads = listOf(thread), - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = thread.folderId, mailbox = mainViewModel.currentMailbox.value!! ) } @@ -210,7 +221,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { trackBottomSheetThreadActionsEvent(MatomoName.Archive, isFromArchive) actionsViewModel.archiveThreads( threads = listOf(thread), - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = thread.folderId, mailbox = mainViewModel.currentMailbox.value!! ) } @@ -220,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() @@ -228,6 +239,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onMove() { val navController = findNavController() + val isFromSearch = navigationArgs.isFromSearch descriptionDialog.moveWithConfirmationPopup(folderRole, count = 1) { trackBottomSheetThreadActionsEvent(MatomoName.Move) navController.animatedNavigation( @@ -235,7 +247,8 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { args = FolderPickerFragmentArgs( threadsUids = arrayOf(navigationArgs.threadUid), action = FolderPickerAction.MOVE, - sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID + sourceFolderId = thread.folderId, + isFromSearch = isFromSearch ).toBundle(), ) } @@ -295,13 +308,11 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { onPositiveButtonClicked = { actionsViewModel.reportPhishing( messages = junkMessages, - currentFolder = mainViewModel.currentFolder.value, + currentFolderId = thread.folderId, mailbox = mainViewModel.currentMailbox.value!!, ) }, ) - - mainViewModel.isMultiSelectOn = false } override fun onBlockSender() { @@ -323,7 +334,6 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { junkMessagesViewModel.messageOfUserToBlock.value = message } } - mainViewModel.isMultiSelectOn = false } 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 f1e419e36a8..c1671c8c249 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/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 89eafe9c80d..47f6f4c117f 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,13 +25,13 @@ 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() - override 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 00000000000..99b30ed265a --- /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: com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView + 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 00000000000..45d5ffcca09 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiSelectionHost.kt @@ -0,0 +1,50 @@ +/* + * 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 +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 + val threadListAdapter: ThreadListAdapter + fun safeNavigation(directions: NavDirections) + fun disableSwipeDirection(direction: DirectionFlag) + fun unlockSwipeActionsIfSet() + fun directionToThreadActionsBottomSheetDialog( + threadUid: String, + shouldLoadDistantResources: Boolean, + shouldCloseMultiSelection: Boolean, + isFromSearch: Boolean, + ): 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 new file mode 100644 index 00000000000..395963aab6d --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/multiselection/MultiselectionViewModel.kt @@ -0,0 +1,85 @@ +/* + * 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.message.Message +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 +class MultiselectionViewModel @Inject constructor( + application: Application, +) : AndroidViewModel(application) { + private val _selectedThreads = mutableSetOf() + val isMultiSelectOnLiveData = MutableLiveData(false) + var isMultiSelectOn: Boolean + get() = isMultiSelectOnLiveData.value ?: false + set(value) { + isMultiSelectOnLiveData.value = value + } + val selectedThreadsLiveData = MutableLiveData(_selectedThreads) + val selectedThreads: MutableSet + get() = _selectedThreads + + val selectedMessages: List + get() = selectedThreads.flatMap { thread -> thread.messages } + + 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() + } + + suspend fun selectOrUnselectAllSearchItems(searchResults: Flow>) { + val results = searchResults.first().list + val isEverythingSelected = isEverythingSelected(results.count()) + if (isEverythingSelected) { + trackMultiSelectionEvent(MatomoName.None) + selectedThreads.clear() + } else { + trackMultiSelectionEvent(MatomoName.All) + results.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 d7e2dfbcf66..1011b79170d 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 3d77a8aea2e..df53f420e10 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 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 05f77358ffa..69e1cd9b653 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( @@ -91,6 +90,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, @@ -111,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) + } } } @@ -134,13 +146,8 @@ class MessagesActions @Inject constructor( threadsUids: List, messagesUids: List? = null, mailbox: Mailbox, - currentFolderId: String?, ): 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) } @@ -150,27 +157,21 @@ class MessagesActions @Inject constructor( messagesToMove = getMessagesFromThreadsToMove(threads) } - return moveMessagesTo(currentFolder, destinationFolder, mailbox, messagesToMove) + return moveMessagesTo(destinationFolder, mailbox, messagesToMove) } // End Region // Spam Region suspend fun toggleMessagesSpamStatus( messages: List, - currentFolderId: String?, mailbox: Mailbox, ): MoveMessagesResult? { - if (currentFolderId == null) return null - val folder = folderController.getFolder(currentFolderId) ?: 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 val unscheduleMessages = messageController.getUnscheduledMessages(messages) - - return moveMessagesTo(folder, destinationFolder, mailbox, unscheduleMessages) + return moveMessagesTo(destinationFolder, mailbox, unscheduleMessages) } suspend fun getMessagesFromThreadsToSpamOrHam(threads: Set): List { @@ -191,14 +192,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, 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 5cf67c50b11..27655280397 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,11 +90,13 @@ 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 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 @@ -410,6 +414,54 @@ 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 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) } diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index 80070caa182..5afef248bd4 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -49,13 +49,17 @@ + + + + - - - - - - - - - - - - + + + - - + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/main_navigation.xml b/app/src/main/res/navigation/main_navigation.xml index c6b173b2150..9cfd2b5d979 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 @@ + + + + + + +